diff --git a/Assets/Scripts/Editor/FlowFieldParityVerify.cs b/Assets/Scripts/Editor/FlowFieldParityVerify.cs index 4f6d94622..90979f7e8 100644 --- a/Assets/Scripts/Editor/FlowFieldParityVerify.cs +++ b/Assets/Scripts/Editor/FlowFieldParityVerify.cs @@ -73,6 +73,7 @@ namespace Aetheria.EditorTools GlobalScrollSpeed = 1f, SlopeAmplitude = 3f, SwirlAmplitude = 5f }; CheckParity("wide", settings, work, compute, wide, new[] { 0.0, 1.0, 17.0, 60.0, 100.0 }); + CheckNoiseClock(compute); Debug.Log($"{Tag} max error {_maxError:G4} of amplitude (tolerance {RelativeTolerance:G4})"); Debug.Log(_ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above"); @@ -100,13 +101,13 @@ namespace Aetheria.EditorTools $"{flow.Period}/{flow.SlopeAmplitude}/{flow.SwirlAmplitude}, authored 512/15/0.025/8/0/0"); } - static Zone BuildZone(GameSettings settings, string work, FlowSettings flow) + internal static Zone BuildZone(GameSettings settings, string work, FlowSettings flow, double time = 0) { var cache = AetheriaStores.Open(Path.Combine(work, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); cache.Upsert(new VerseGrammar { Revision = 1 }); cache.FlushAsync().Wait(); var items = new ItemManager(cache, new ProvenanceLedger(), settings.GameplaySettings, _ => { }); - return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack(), new GalaxyZone { Name = "Parity", Owner = null }, null); + return new Zone(items, new PlanetSettings { Flow = flow }, new ZonePack { Time = time }, new GalaxyZone { Name = "Parity", Owner = null }, null); } static void CheckParity(string label, GameSettings settings, string work, ComputeShader compute, FlowSettings flow, @@ -137,8 +138,18 @@ namespace Aetheria.EditorTools var globalBuffer = new ComputeBuffer(count, 12); var slopeBuffer = new ComputeBuffer(count, 12); var gatedBuffer = new ComputeBuffer(count, 12); - // flow() reads the nebula's gravity height under FLOW_SLOPE; a flat (all zero) height is the flat zone's. - var height = new Texture2D(1, 1, TextureFormat.RFloat, false); + ComputeBuffer noiseBuffer = null; + // flow() reads the nebula's gravity height under FLOW_SLOPE. A ramp rising along the texture's x gives every + // position inside the grid the gradient direction (1, 0), so the slope term is live where a flat height + // would zero it (and the FLOW_SLOPE keyword and its pragma would not matter). + const int rampSize = 64; + var height = new Texture2D(rampSize, rampSize, TextureFormat.RFloat, false) + { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp }; + var ramp = new float[rampSize * rampSize]; + for (var i = 0; i < ramp.Length; i++) ramp[i] = (i % rampSize) / (float) (rampSize - 1); + height.SetPixelData(ramp, 0); + height.Apply(); + var rampGradient = new CultMath.float2(1f, 0f); try { positionBuffer.SetData(positions); @@ -149,6 +160,11 @@ namespace Aetheria.EditorTools compute.SetBuffer(kernel, "slopeOut", slopeBuffer); compute.SetBuffer(kernel, "gatedOut", gatedBuffer); compute.SetTexture(kernel, "_NebulaSurfaceHeight", height); + compute.SetVector("_NebulaSurfaceHeight_TexelSize", new Vector4(1f / rampSize, 1f / rampSize, rampSize, rampSize)); + // The grid spans 40000: every sampled position (|xz| to 10000) is inside the ramp's uv range. + Shader.SetGlobalVector("_GridTransform", new Vector4(0f, 0f, 40000f, 0f)); + noiseBuffer = new ComputeBuffer(count, 4); + compute.SetBuffer(kernel, "noiseOut", noiseBuffer); compute.SetInt("count", count); var elapsed = 0.0; @@ -190,18 +206,19 @@ namespace Aetheria.EditorTools var expected = FlowField.Global(flow, new CultMath.float3(p.x, p.y, p.z), zoneScroll); Compare($"{label}-global", at, i, new Vector3(expected.x, expected.y, expected.z), globalOut[i], flow.GlobalAmplitude); } - // flow() is the keyword-gated function the nebula and stardust call; the zone's flat well has a zero - // gradient, so Zone.Flow is the global term here. A flow that is live must not read zero. + // flow() is the keyword-gated function the nebula and stardust call: the global term plus, under + // FLOW_SLOPE, the slope term of the ramp's gradient. A flow that is live must not read zero. var live = 0; + var gatedScale = Math.Max(Math.Abs(flow.GlobalAmplitude), Math.Max(Math.Abs(flow.SlopeAmplitude), Math.Abs(flow.SwirlAmplitude))); for (var i = 0; i < positionCount; i++) { var p = positions[i]; - var expected = zone.Flow(new CultMath.float3(p.x, p.y, p.z)); + var expected = FlowField.At(flow, new CultMath.float3(p.x, p.y, p.z), zoneScroll, rampGradient); var e = new Vector3(expected.x, expected.y, expected.z); - if (e.magnitude > .1f * flow.GlobalAmplitude) live++; - Compare($"{label}-flow-keywords", at, i, e, gatedOut[i], flow.GlobalAmplitude); + if (e.magnitude > .1f * gatedScale) live++; + Compare($"{label}-flow-keywords", at, i, e, gatedOut[i], gatedScale); } - if (flow.GlobalAmplitude != 0f && live < positionCount / 4) + if (gatedScale != 0f && live < positionCount / 4) Fail($"{label}-flow-keywords", $"{at}: only {live} of {positionCount} zone flows are live; the check is vacuous"); var slopeScale = Math.Max(Math.Abs(flow.SlopeAmplitude), Math.Abs(flow.SwirlAmplitude)); for (var i = 0; i < gradientCount; i++) @@ -214,7 +231,60 @@ namespace Aetheria.EditorTools } finally { - positionBuffer.Release(); gradientBuffer.Release(); globalBuffer.Release(); slopeBuffer.Release(); gatedBuffer.Release(); UnityEngine.Object.DestroyImmediate(height); + positionBuffer.Release(); gradientBuffer.Release(); globalBuffer.Release(); slopeBuffer.Release(); gatedBuffer.Release(); noiseBuffer?.Release(); UnityEngine.Object.DestroyImmediate(height); + } + } + + // The nebula noise the flow advects evolves on the flow clock VolumeSampling lowers as _NebulaNoiseTime, never on + // _Time: with the speed non-zero, the noise changes when _NebulaNoiseTime does and not otherwise (a frame clock + // would not follow the global set here). + static void CheckNoiseClock(ComputeShader compute) + { + const int count = 64; + var kernel = compute.FindKernel("Evaluate"); + var rng = new System.Random(4217); + var positions = new Vector3[count]; + for (var i = 0; i < count; i++) + positions[i] = new Vector3((float) (rng.NextDouble() * 4000 - 2000), (float) (rng.NextDouble() * 100 - 50), (float) (rng.NextDouble() * 4000 - 2000)); + var buffers = new[] { new ComputeBuffer(count, 12), new ComputeBuffer(count, 8), new ComputeBuffer(count, 12), new ComputeBuffer(count, 12), new ComputeBuffer(count, 12), new ComputeBuffer(count, 4) }; + var height = new Texture2D(1, 1, TextureFormat.RFloat, false); + try + { + buffers[0].SetData(positions); + buffers[1].SetData(new Vector2[count]); + compute.SetBuffer(kernel, "positions", buffers[0]); + compute.SetBuffer(kernel, "gradients", buffers[1]); + compute.SetBuffer(kernel, "globalOut", buffers[2]); + compute.SetBuffer(kernel, "slopeOut", buffers[3]); + compute.SetBuffer(kernel, "gatedOut", buffers[4]); + compute.SetBuffer(kernel, "noiseOut", buffers[5]); + compute.SetTexture(kernel, "_NebulaSurfaceHeight", height); + compute.SetInt("count", count); + Shader.SetGlobalFloat("_NebulaNoiseSpeed", 1f); + float[] Noise(float noiseTime) + { + Shader.SetGlobalFloat("_NebulaNoiseTime", noiseTime); + compute.Dispatch(kernel, 1, 1, 1); + var result = new float[count]; + buffers[5].GetData(result); + return result; + } + var a = Noise(0f); + var b = Noise(3f); + var again = Noise(3f); + int moved = 0, repeated = 0; + for (var i = 0; i < count; i++) + { + if (Math.Abs(a[i] - b[i]) > 1e-4f) moved++; + if (b[i] == again[i]) repeated++; + } + if (moved < count / 2) Fail("nebula-noise-clock", $"only {moved} of {count} noise samples follow _NebulaNoiseTime"); + if (repeated != count) Fail("nebula-noise-clock", $"{count - repeated} of {count} noise samples changed with _NebulaNoiseTime held"); + } + finally + { + foreach (var buffer in buffers) buffer.Release(); + UnityEngine.Object.DestroyImmediate(height); } } diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs new file mode 100644 index 000000000..80d9a4e31 --- /dev/null +++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs @@ -0,0 +1,181 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Aetheria.EditorTools +{ + /// + /// Batchmode check for the flow as the scene runs it (campaign aetheria-fauna, cut fauna-flow-field; target + /// invariants flow-one-owner and flow-shader-parity). FlowFieldParityVerify proves the HLSL against FlowField with + /// no scene loaded; this opens ARPG.unity, lets VolumeSampling and Stardust initialise as the game does, drives + /// their own Start and Update with a zone attached, and asserts what the presenters did: + /// - nothing threw and nothing logged an error (a type that cannot initialise in a scene, as VolumeSampling once + /// could when it created its keywords in a static field initializer, throws on its first use); + /// - FLOW_GLOBAL and FLOW_SLOPE are enabled, _FlowAmplitude is the zone's, and the nebula noise clock is the zone's; + /// - the stardust particle buffer changes with the flow and freezes when the zone pauses; + /// - CloudShader pass 0 carries the FLOW_* keywords. + /// The scene is opened before any Aetheria type is touched: run this method alone in its own Unity session, never + /// after FlowFieldParityVerify (which would initialise VolumeSampling outside a MonoBehaviour constructor). + /// Dispatches compute and renders, so it needs a graphics device: run WITHOUT -nographics. + /// Unity -batchmode -projectPath <repo> -executeMethod Aetheria.EditorTools.FlowFieldSceneVerify.Run -logFile <path> + /// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. + /// + public static class FlowFieldSceneVerify + { + const string Tag = "[FlowFieldSceneVerify]"; + static bool _ok = true; + + static void OnLog(string message, string stack, LogType type) + { + if ((type == LogType.Error || type == LogType.Exception || type == LogType.Assert) && !message.StartsWith(Tag)) + Fail("logged-error", message.Split('\n')[0]); + } + + static void Fail(string check, string detail) + { + Debug.LogError($"{Tag} FAIL {check}: {detail}"); + _ok = false; + } + + public static void Run() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Debug.LogError($"{Tag} FAIL: no graphics device in this session; rerun batchmode without -nographics."); + EditorApplication.Exit(1); + return; + } + + Application.logMessageReceived += OnLog; + try + { + // Before anything else touches the project's types: the scene constructs its MonoBehaviours here. + EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Single); + Check(); + Debug.Log(_ok ? $"{Tag} PASS" : $"{Tag} FAIL: see errors above"); + } + catch (Exception e) + { + Debug.LogError($"{Tag} FAIL exception: {e}"); + _ok = false; + } + finally + { + Application.logMessageReceived -= OnLog; + } + EditorApplication.Exit(_ok ? 0 : 1); + } + + static void Check() + { + var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset") + ?? throw new InvalidOperationException("No Settings.asset."); + var agm = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault(); + var volume = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault(); + var dust = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault(); + if (agm == null || volume == null || dust == null) + { + Fail("scene-objects", $"ARPG.unity has ActionGameManager={agm != null}, VolumeSampling={volume != null}, Stardust={dust != null}"); + return; + } + + // Every flow term live, so FLOW_SLOPE is on; and the same flow with every amplitude zero. + var on = new FlowSettings + { + GlobalScale = 512f, GlobalAmplitude = 15f, GlobalScrollSpeed = .025f, Period = 8f, SlopeAmplitude = 3f, SwirlAmplitude = 5f + }; + var off = new FlowSettings { GlobalScale = on.GlobalScale, GlobalScrollSpeed = on.GlobalScrollSpeed, Period = on.Period }; + var work = Path.Combine(Path.GetTempPath(), "flow-scene-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(work); + try + { + var zone = FlowFieldParityVerify.BuildZone(settings, work, on, 86400.0 * 3 + 12.3); + typeof(ActionGameManager).GetProperty("Instance").GetSetMethod(true).Invoke(null, new object[] { agm }); + typeof(ActionGameManager).GetProperty("Zone").GetSetMethod(true).Invoke(agm, new object[] { zone }); + if (agm.Settings == null) agm.Settings = settings; + + Call(volume, "Start"); + Call(volume, "Update"); + if (!Shader.IsKeywordEnabled("FLOW_GLOBAL")) Fail("scene-keywords", "FLOW_GLOBAL is not enabled after VolumeSampling.Update"); + if (!Shader.IsKeywordEnabled("FLOW_SLOPE")) Fail("scene-keywords", "FLOW_SLOPE is not enabled after VolumeSampling.Update"); + if (Shader.GetGlobalFloat("_FlowAmplitude") != on.GlobalAmplitude) + Fail("scene-amplitude", $"_FlowAmplitude is {Shader.GetGlobalFloat("_FlowAmplitude")}, the zone's flow has {on.GlobalAmplitude}"); + var scroll = Shader.GetGlobalVector("_FlowScroll"); + if (scroll.x != zone.FlowScroll.x || scroll.y != zone.FlowScroll.y) + Fail("scene-scroll", $"_FlowScroll ({scroll.x}, {scroll.y}) is not the zone's ({zone.FlowScroll.x}, {zone.FlowScroll.y})"); + var noiseTime = Shader.GetGlobalFloat("_NebulaNoiseTime"); + if (noiseTime != (float) zone.ExactTime) + Fail("scene-noise-clock", $"_NebulaNoiseTime is {noiseTime}, the zone's time is {(float) zone.ExactTime}"); + + CheckCloudKeywords(); + + // Stardust follows the flow through its own dispatch: the particle buffer read back each frame. + Call(dust, "Start"); + var bufferField = typeof(Stardust).GetField("_particlesBuffer", BindingFlags.NonPublic | BindingFlags.Instance); + float[] Frame() + { + Call(dust, "Update"); + var buffer = (ComputeBuffer) bufferField.GetValue(dust); + var data = new float[buffer.count * 7]; + buffer.GetData(data); + return data; + } + var flowing = Frame(); + VolumeSampling.LowerFlow(off, VolumeSampling.FlowClock); + var still = Frame(); + if (Differing(flowing, still) == 0) Fail("scene-stardust", "the particle buffer is the same with the zone's flow and with no flow"); + + // A paused zone freezes the particles and the nebula noise clock; a running one moves both. + Call(volume, "Update"); + zone.Update(0f); + Call(volume, "Update"); + if (Differing(flowing, Frame()) != 0) Fail("scene-stardust-pause", "the particle buffer moved while the zone was paused"); + if (Shader.GetGlobalFloat("_NebulaNoiseTime") != noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime moved while the zone was paused"); + zone.Update(.05f); + Call(volume, "Update"); + if (Differing(flowing, Frame()) == 0) Fail("scene-stardust-run", "the particle buffer did not move while the zone ran"); + if (Shader.GetGlobalFloat("_NebulaNoiseTime") == noiseTime) Fail("scene-noise-clock", "_NebulaNoiseTime did not move while the zone ran"); + } + finally + { + try { Directory.Delete(work, true); } catch { } + } + } + + // CloudShader's pass 0 compiles with the flow keywords (it must #include_with_pragmas the shared include). + static void CheckCloudKeywords() + { + var shader = AssetDatabase.LoadAssetAtPath("Assets/Shaders/Raymarching/CloudShader.shader"); + if (shader == null) { Fail("scene-cloud-keywords", "No CloudShader.shader."); return; } + var keywords = ShaderUtil.GetPassKeywords(shader, new PassIdentifier(0, 0), UnityEditor.Rendering.ShaderType.Fragment) + .Select(keyword => keyword.name).ToArray(); + foreach (var name in new[] { "FLOW_GLOBAL", "FLOW_SLOPE" }) + if (!keywords.Contains(name)) Fail("scene-cloud-keywords", $"CloudShader pass 0 does not carry {name}"); + } + + static int Differing(float[] a, float[] b) + { + var count = 0; + for (var i = 0; i < a.Length; i++) + if (Math.Abs(a[i] - b[i]) > 1e-4f) count++; + return count; + } + + // The presenters' own Start and Update, as the game calls them; an exception is a failure that names the type. + static void Call(object target, string method) + { + var info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + if (info == null) { Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} not found"); return; } + try { info.Invoke(target, null); } + catch (TargetInvocationException e) + { + Fail("scene-presenter-exception", $"{target.GetType().Name}.{method} threw {e.InnerException?.GetType().Name}"); + } + } + } +} diff --git a/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta new file mode 100644 index 000000000..77339880b --- /dev/null +++ b/Assets/Scripts/Editor/FlowFieldSceneVerify.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 25fbd79fca1de49498c3bebf9f63243b \ No newline at end of file diff --git a/Assets/Shaders/Compute/FlowParity.compute b/Assets/Shaders/Compute/FlowParity.compute index 463ec7f1a..a431accef 100644 --- a/Assets/Shaders/Compute/FlowParity.compute +++ b/Assets/Shaders/Compute/FlowParity.compute @@ -13,6 +13,7 @@ StructuredBuffer gradients; RWStructuredBuffer globalOut; RWStructuredBuffer slopeOut; RWStructuredBuffer gatedOut; // flow(): the keyword-gated function the nebula and stardust call +RWStructuredBuffer noiseOut; // triNoise3d: the nebula noise the flow advects uint count; [numthreads(64, 1, 1)] @@ -22,4 +23,5 @@ void Evaluate(uint3 id : SV_DispatchThreadID) globalOut[id.x] = globalFlow(positions[id.x]); slopeOut[id.x] = slopeFlow(gradients[id.x]); gatedOut[id.x] = flow(positions[id.x]); + noiseOut[id.x] = triNoise3d(positions[id.x] / 512); } diff --git a/tests/mutation_tests_flow_parity.py b/tests/mutation_tests_flow_parity.py index 2220f3277..d0893e702 100644 --- a/tests/mutation_tests_flow_parity.py +++ b/tests/mutation_tests_flow_parity.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Mutation tests for the flow-shader-parity rule (campaign aetheria-fauna, cut fauna-flow-field). +Assets/Scripts/Editor/FlowFieldSceneVerify.cs opens ARPG.unity and drives VolumeSampling and Stardust as the game +does (the keywords, the stardust buffer, CloudShader's keywords); each mutant below names the check that must catch it. Assets/Scripts/Editor/FlowFieldParityVerify.cs evaluates the HLSL globalFlow and slopeFlow (Assets/Shaders/Volumetric.cginc, through FlowParity.compute) and FlowField (ServerShared) for the same positions, scrolls, settings and gradients, after VolumeSampling.LowerFlow has lowered a stepped @@ -25,6 +27,11 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe" +PARITY = "Aetheria.EditorTools.FlowFieldParityVerify.Run" +SCENE = "Aetheria.EditorTools.FlowFieldSceneVerify.Run" +STARDUST_COMPUTE = "Assets/Shaders/Compute/Stardust/Stardust.compute" +PARITY_COMPUTE = "Assets/Shaders/Compute/FlowParity.compute" +CLOUD_SHADER = "Assets/Shaders/Raymarching/CloudShader.shader" CGINC = "Assets/Shaders/Volumetric.cginc" VOLUME_SAMPLING = "Assets/Scripts/Zone Display/VolumeSampling.cs" FLOW_FIELD = "Assets/Scripts/ServerShared/FlowField.cs" @@ -47,14 +54,20 @@ MUTANTS = [ ("lower-keywords-lookup-only", VOLUME_SAMPLING, 'GlobalKeyword.Create("FLOW_GLOBAL")', 'new GlobalKeyword("FLOW_GLOBAL")', "flow-keywords"), ("csharp-scale-doubled", FLOW_FIELD, "var field = position / settings.GlobalScale;", "var field = position / (settings.GlobalScale * 2f);", "global"), ("asset-amplitude", SETTINGS_ASSET, " GlobalAmplitude: 15\n", " GlobalAmplitude: 14\n", "authored-flow"), + ("hlsl-noise-frame-clock", CGINC, "_NebulaNoiseTime * _NebulaNoiseSpeed", "_Time.y * _NebulaNoiseSpeed", "nebula-noise-clock"), + ("parity-slope-pragma-removed", PARITY_COMPUTE, "#pragma multi_compile __ FLOW_SLOPE", "", "flow-keywords"), + ("scene-stardust-pragma-removed", STARDUST_COMPUTE, "#pragma multi_compile __ FLOW_GLOBAL", "", "scene-stardust", SCENE), + ("scene-cloud-include-without-pragmas", CLOUD_SHADER, '#include_with_pragmas "Assets/Shaders/Volumetric.cginc"', '#include "Assets/Shaders/Volumetric.cginc"', "scene-cloud-keywords", SCENE), + ("scene-keyword-in-field-initializer", VOLUME_SAMPLING, "static GlobalKeyword? _keywordFlowGlobal;", 'static GlobalKeyword? _keywordFlowGlobal = GlobalKeyword.Create("FLOW_GLOBAL");', "scene-presenter-exception", SCENE), + ("scene-noise-time-frame-clock", VOLUME_SAMPLING, '"_NebulaNoiseTime", (float) clock', '"_NebulaNoiseTime", Time.realtimeSinceStartup', "scene-noise-clock", SCENE), ] -def run_probe(log: Path) -> tuple[int, str]: +def run_probe(log: Path, entry: str = PARITY) -> tuple[int, str]: log.unlink(missing_ok=True) proc = subprocess.run( [UNITY_EXE, "-batchmode", "-projectPath", str(REPO_ROOT), - "-executeMethod", "Aetheria.EditorTools.FlowFieldParityVerify.Run", "-logFile", str(log)], + "-executeMethod", entry, "-logFile", str(log)], timeout=1800, ) return proc.returncode, log.read_text(encoding="utf-8", errors="replace") @@ -68,14 +81,16 @@ def main() -> int: scratch.mkdir(parents=True, exist_ok=True) failures: list[str] = [] - print("\n=== control: unmutated tree ===") - code, text = run_probe(scratch / "control.log") - if code != 0 or "[FlowFieldParityVerify] PASS" not in text: - print("control FAILED: expected exit 0 and PASS on the unmutated tree") - return 1 - print("control OK: exit 0, PASS") + for entry, name in ((PARITY, "FlowFieldParityVerify"), (SCENE, "FlowFieldSceneVerify")): + print(f"\n=== control: unmutated tree, {name} ===") + code, text = run_probe(scratch / f"control-{name}.log", entry) + if code != 0 or f"[{name}] PASS" not in text: + print("control FAILED: expected exit 0 and PASS on the unmutated tree") + return 1 + print("control OK: exit 0, PASS") - for label, rel, before, after, check in MUTANTS: + for label, rel, before, after, check, *rest in MUTANTS: + entry = rest[0] if rest else PARITY if only and label not in only: continue path = REPO_ROOT / rel @@ -89,7 +104,7 @@ def main() -> int: print(f"\n=== mutant {label} ({rel}) ===") try: path.write_bytes(text_in.replace(b, a).encode("utf-8")) - code, text = run_probe(scratch / f"{label}.log") + code, text = run_probe(scratch / f"{label}.log", entry) finally: path.write_bytes(original) named = f"FAIL {check}" in text or f"-{check}:" in text