FlowFieldSceneVerify: the flow as ARPG.unity runs it; parity check on a ramp height and the noise clock

FlowFieldSceneVerify opens ARPG.unity before anything touches the project's types, drives VolumeSampling and Stardust
Start/Update with a zone attached, and asserts no exception or logged error, FLOW_GLOBAL and FLOW_SLOPE enabled,
_FlowAmplitude non-zero, the stardust buffer changing with the flow and freezing with a paused zone, the noise clock,
and CloudShader pass 0 carrying the FLOW_* keywords. FlowFieldParityVerify now binds a ramp height so FLOW_SLOPE and its
pragma matter, and checks the noise follows _NebulaNoiseTime. The mutation harness runs both checks, with six new mutants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 23:25:45 +02:00
commit 819bd79424
5 changed files with 291 additions and 21 deletions

View file

@ -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);
}
}

View file

@ -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
{
/// <summary>
/// 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 &lt;repo&gt; -executeMethod Aetheria.EditorTools.FlowFieldSceneVerify.Run -logFile &lt;path&gt;
/// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure.
/// </summary>
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<GameSettings>("Assets/Resources/Settings.asset")
?? throw new InvalidOperationException("No Settings.asset.");
var agm = UnityEngine.Object.FindObjectsByType<ActionGameManager>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
var volume = UnityEngine.Object.FindObjectsByType<VolumeSampling>(FindObjectsInactive.Include, FindObjectsSortMode.None).FirstOrDefault();
var dust = UnityEngine.Object.FindObjectsByType<Stardust>(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<Shader>("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}");
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 25fbd79fca1de49498c3bebf9f63243b

View file

@ -13,6 +13,7 @@ StructuredBuffer<float2> gradients;
RWStructuredBuffer<float3> globalOut;
RWStructuredBuffer<float3> slopeOut;
RWStructuredBuffer<float3> gatedOut; // flow(): the keyword-gated function the nebula and stardust call
RWStructuredBuffer<float> 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);
}

View file

@ -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