Add the Cut 3 batchmode probe and its mutation harness

ShieldPanelCut3Verify.cs stands in for NUnit coverage the same way Cut 1 and
Cut 2's probes do (Tests.asmdef cannot see Assembly-CSharp). It builds an
envelope/prototype/interceptor rig purely in code and steps ShieldPanel's
private Update() by reflection, since Cut 1 removed [ExecuteAlways] and
Update() otherwise never runs in edit mode. It needs GPU access, so unlike
Cut 1/2's probes it runs without -nographics.

Checks: same-frame injection (D17), the gate staying open on the following
frame (D20), reuse striking the existing panel and eroding its minimum temper,
pool exhaustion holding the cap with no allocation once full, null-guarded
Absorb, no rebuild across a pool return/reuse cycle (driven directly via
OnEnable/OnDisable reflection, since Object.Instantiate's OnEnable is not
synchronous in every context this runs in), and the D21 Penrose/Hex density
ratio at radius 1.5/3/5/10.

mutation_tests_shield_panel_cut3.py mutates each rule Cut 3 establishes back
to its pre-fix or pre-rule shape and asserts the probe goes red naming it,
plus a no-op control. Kept separate from _cut1.py/_cut2.py (each owns its own
cut's rules) and from mutation_tests.py (the item-provenance cut's headless
dotnet runner, no path to a Unity batchmode check).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 13:51:44 +02:00
commit 5ae5c518a2
3 changed files with 690 additions and 0 deletions

View file

@ -0,0 +1,466 @@
using System.Reflection;
using CultMath.UnityBridge;
using ShieldField;
using UnityEditor;
using UnityEngine;
namespace Aetheria.EditorTools
{
/// <summary>
/// Batchmode probe for the shield-panel Cut 3 landing (docs/shield-panel-cut.md): the
/// interceptor presenter (ShieldInterceptor) and the panel pool. Tests.asmdef cannot see
/// Assembly-CSharp (the map's Q7), so this stands in for NUnit coverage exactly as Cut 1 and
/// Cut 2's probes do, and it runs the rules those probes cannot reach: same-frame injection
/// (D17/D20), reuse-strikes-the-existing-panel, pool exhaustion, and the D21 Penrose/Hex
/// density ratio.
///
/// Unlike Cut 1/2 this exercises actual MonoBehaviour simulation state (GraphicsBuffer
/// dispatches), which needs GPU access -- run WITHOUT -nographics, unlike the two probes before
/// it. ShieldPanel deliberately has no [ExecuteAlways] (Cut 1 removed it, D13), so Update()
/// never runs on its own in edit mode; this probe steps the simulation itself by invoking the
/// private Update() method directly; it is Application.isPlaying == false throughout, so
/// ShieldPanel's own dt fallback (1/60s, fixed) is exactly what's exercised.
///
/// Run via: Unity -batchmode -projectPath &lt;repo&gt; -executeMethod
/// Aetheria.EditorTools.ShieldPanelCut3Verify.Run -logFile &lt;path&gt;
/// (no -quit while iterating: EditorApplication.Exit both reports the code and quits.)
/// </summary>
public static class ShieldPanelCut3Verify
{
static readonly MethodInfo PanelUpdate =
typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance);
static readonly MethodInfo PanelOnEnable =
typeof(ShieldPanel).GetMethod("OnEnable", BindingFlags.NonPublic | BindingFlags.Instance);
static readonly MethodInfo PanelOnDisable =
typeof(ShieldPanel).GetMethod("OnDisable", BindingFlags.NonPublic | BindingFlags.Instance);
public static void Run()
{
bool ok = true;
ok &= CheckSpawnFrameInjectionAbsorbs();
ok &= CheckReuseStrikesExistingPanel();
ok &= CheckPoolExhaustion();
ok &= CheckNullGuardsDoNotThrow();
ok &= CheckPoolReuseDoesNotRebuild();
ok &= CheckPenroseDensity();
Debug.Log(ok
? "[ShieldPanelCut3Verify] PASS"
: "[ShieldPanelCut3Verify] FAIL — see errors above");
EditorApplication.Exit(ok ? 0 : 1);
}
// ==============================================================
static void StepPanel(ShieldPanel panel) => PanelUpdate.Invoke(panel, null);
static (GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor)
BuildRig(int maxLive = 12, float panelRadius = 2f, float reuseFraction = 1f)
{
var sim = AssetDatabase.LoadAssetAtPath<ComputeShader>(
"Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
var shader = AssetDatabase.LoadAssetAtPath<Shader>(
"Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
// Uniform-scale sphere at the origin: no MeshCollider/MeshFilter, so ShieldEnvelope
// takes its documented no-mesh fallback (_localExtents = Vector3.one) rather than
// needing a real hull mesh -- this probe is about the interceptor's plumbing, and
// Cut 2's own probe already pins the mesh-extents/normal arithmetic.
var envelopeGO = new GameObject("Cut3Probe_Envelope");
envelopeGO.transform.localScale = Vector3.one * 5f;
var envelope = envelopeGO.AddComponent<ShieldEnvelope>();
var protoGO = new GameObject("Cut3Probe_PanelPrototype");
var panel = protoGO.AddComponent<ShieldPanel>();
// Assigned after AddComponent so the OnEnable this triggers (sim/panelShader still
// null at that point) is a no-op -- Rebuild() below is the real, deliberate build,
// exactly as Cut 4's rig depends on the prototype's authored fields being set before
// its first real build, not on whatever OnEnable saw at GameObject-creation time.
panel.tiling = TilingKind.Hex;
panel.panelRadius = panelRadius;
panel.cellSize = 0.5f;
panel.sim = sim;
panel.panelShader = shader;
panel.Rebuild();
var proto = protoGO.AddComponent<Prototype>();
var interceptorGO = new GameObject("Cut3Probe_Interceptor");
var interceptor = interceptorGO.AddComponent<ShieldInterceptor>();
interceptor.Envelope = envelope;
interceptor.PanelPrototype = proto;
interceptor.MaxLivePanels = maxLive;
interceptor.PanelRadius = panelRadius;
interceptor.ReuseFraction = reuseFraction;
return (envelopeGO, envelope, protoGO, interceptor);
}
static void DestroyRig((GameObject envelopeGO, ShieldEnvelope envelope, GameObject protoGO, ShieldInterceptor interceptor) rig)
{
// The prototype's own pooled instances are children of protoGO's parent (null here, i.e.
// scene root) per Prototype.Instantiate's `SetParent(transform.parent, ...)` -- find and
// destroy them too so one check's panels can't be mistaken for the next check's.
foreach (var go in UnityEngine.Object.FindObjectsByType<ShieldPanel>(FindObjectsSortMode.None))
UnityEngine.Object.DestroyImmediate(go.gameObject);
UnityEngine.Object.DestroyImmediate(rig.envelopeGO);
UnityEngine.Object.DestroyImmediate(rig.interceptor.gameObject);
}
static AbsorbEvent Absorb(Vector3 worldPos, Vector3 worldDir, float energy = 5f,
DamageType type = DamageType.Kinetic)
=> new AbsorbEvent(worldPos.ToCultMath(), worldDir.ToCultMath(), energy, type);
// ==============================================================
// D17/D20: a panel struck in its own spawn frame must absorb that hit, not swallow it.
// ==============================================================
static bool CheckSpawnFrameInjectionAbsorbs()
{
var rig = BuildRig();
try
{
rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] spawn-frame: expected 1 live panel, got {rig.interceptor.LiveCount}.");
return false;
}
var panel = rig.interceptor.DebugPanelAt(0);
// The strike lands at the panel's own local origin (ShieldInterceptor poses the
// panel's transform AT the intercept point before calling Hit with that same
// point), so the nearest cell to local (0,0) is the one that must show injection.
int nearest = NearestCell(panel, Vector2.zero);
// Exactly one Update() call: the frame the hit was queued in.
StepPanel(panel);
var v = new float[panel.DebugCellCount];
panel.DebugVField.GetData(v);
if (Mathf.Abs(v[nearest]) < 1e-6f)
{
Debug.LogError($"[ShieldPanelCut3Verify] D17 not fixed: cell {nearest}'s wave velocity is " +
$"{v[nearest]:E3} after the spawn-frame hit -- the growth gate swallowed it.");
return false;
}
// D20: the panel's own arm clock must keep the gate open on the NEXT frame too, not
// just the spawn frame. KWaveStep zeroes both _UOut and _V outright for any cell
// whose growth is still < 0.6 (ShieldSim.compute), so under the pre-Cut-3 bug
// (_PanelSpawnTime hardcoded to 0f) growth gets reset to 0 by frame 1's own KUpdate
// and frame 2 is gated shut again -- a fresh injection landing on frame 2 can only
// push v MORE negative (compression) than whatever frame 1 left behind; the bug
// instead zeroes it outright. Decay alone (the fix, with no second hit) could only
// shrink |v| toward zero, never make it more negative, so this is a clean
// discriminator between "still gated" and "correctly still open".
float vBeforeSecondHit = v[nearest];
panel.Hit(panel.transform.position, new Vector3(0, 0, -1), 5f);
StepPanel(panel);
var v2 = new float[panel.DebugCellCount];
panel.DebugVField.GetData(v2);
if (!(v2[nearest] < vBeforeSecondHit - 1e-4f))
{
Debug.LogError($"[ShieldPanelCut3Verify] D20 not fixed: a second hit queued for frame 2 did not " +
$"inject (v {vBeforeSecondHit:E3} -> {v2[nearest]:E3}) -- growth likely regressed " +
"back below the gate between frame 1 and frame 2.");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] D17/D20 OK: frame 1 |v|={Mathf.Abs(v[nearest]):E3}, " +
$"frame 2 (after a second queued hit) v={v2[nearest]:E3} -- the gate stayed open.");
return true;
}
finally { DestroyRig(rig); }
}
static int NearestCell(ShieldPanel panel, Vector2 localPoint)
{
int best = 0;
float bestD2 = float.MaxValue;
for (int i = 0; i < panel.DebugCellCount; i++)
{
float d2 = (panel.DebugCellCenter(i) - localPoint).sqrMagnitude;
if (d2 < bestD2) { bestD2 = d2; best = i; }
}
return best;
}
// ==============================================================
// Reuse: a second hit inside a living panel strikes it rather than spawning a new one, and
// the strike actually reaches the material (temper falls), not merely "panel count stayed
// the same" -- a silently dropped second event would pass a panel-count-only check too.
// ==============================================================
static bool CheckReuseStrikesExistingPanel()
{
var rig = BuildRig(maxLive: 4);
try
{
var p1 = new Vector3(0, 0, 20);
var p2 = new Vector3(0.3f, 0, 20); // projects close to p1 -- inside PanelRadius(2)*ReuseFraction(1)
var pFar = new Vector3(20, 0, 0); // projects to (5,0,0) -- ~7 units from (0,0,5), well past the reuse radius
rig.interceptor.Absorb(Absorb(p1, new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 1: expected 1 live panel, got {rig.interceptor.LiveCount}.");
return false;
}
var panel = rig.interceptor.DebugPanelAt(0);
// Step until the struck region is past the growth threshold and the first hit's
// temper erosion has had time to register (growDuration default 0.35s @ 1/60 ~= 21
// frames; erosion is also gated to whichever frames catch the wave in tension --
// KFracture's `if (u <= 0) return` skips compression frames entirely -- so give it
// several oscillation cycles, not just enough frames to grow).
for (int i = 0; i < 40; i++) StepPanel(panel);
// The map's own wording: compare the panel's MINIMUM temper, not one hand-picked
// cell's -- a single cell can sit in a compression phase at the instant we sample
// even though the strike clearly reached the material (KFracture only erodes temper
// on a tension frame), so pinning one cell is a false-negative trap. The minimum
// across the whole panel is what the map's reuse test actually asks for.
float temperAfterFirst = MinTemper(panel);
rig.interceptor.Absorb(Absorb(p2, new Vector3(0, 0, -1)));
if (rig.interceptor.LiveCount != 1)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: a nearby second hit spawned a new panel " +
$"(live count {rig.interceptor.LiveCount}) instead of reusing the existing one.");
return false;
}
if (rig.interceptor.DebugPanelAt(0) != panel)
{
Debug.LogError("[ShieldPanelCut3Verify] reuse step 2: live count stayed 1 but the panel identity changed.");
return false;
}
for (int i = 0; i < 40; i++) StepPanel(panel);
float temperAfterSecond = MinTemper(panel);
if (!(temperAfterSecond < temperAfterFirst - 1e-5f))
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 2: min temper did not fall from the second hit " +
$"({temperAfterFirst:F4} -> {temperAfterSecond:F4}) -- the strike reached the panel " +
"but not the material.");
return false;
}
rig.interceptor.Absorb(Absorb(pFar, new Vector3(-1, 0, 0)));
if (rig.interceptor.LiveCount != 2)
{
Debug.LogError($"[ShieldPanelCut3Verify] reuse step 3: a far strike should spawn a second panel " +
$"(live count {rig.interceptor.LiveCount}, expected 2).");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] reuse OK: temper {temperAfterFirst:F4} -> {temperAfterSecond:F4} on reuse; " +
"a far strike opened a second panel.");
return true;
}
finally { DestroyRig(rig); }
}
static float MinTemper(ShieldPanel panel)
{
var raw = new CellStateRaw[panel.DebugCellCount];
panel.DebugStateBuffer.GetData(raw);
float min = float.MaxValue;
foreach (var s in raw) min = Mathf.Min(min, s.temper);
return min;
}
// Mirrors ShieldField.CellState's layout for GraphicsBuffer.GetData -- kept local to the
// probe rather than reusing ShieldField.CellState directly so a future field reorder in the
// production struct (which IS this probe's business to catch) shows up as a stride/offset
// mismatch here rather than the probe silently reading through the same accidental typo.
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
struct CellStateRaw
{
public Vector3 vel; public float spin; public Vector3 pos; public float rot;
public float damage; public float breakTime; public float glow; public float growth;
public float temper; public float released; public float peakTension; public float pad2;
}
// ==============================================================
// Pool exhaustion: MaxLivePanels+3 widely-separated strikes must never exceed the cap, and
// must not drop a strike -- the oldest live panel is recycled in place.
// ==============================================================
static bool CheckPoolExhaustion()
{
const int cap = 3;
var rig = BuildRig(maxLive: cap, panelRadius: 1f);
try
{
// Spread strikes around the equator, far enough apart (PanelRadius*3+) that none reuse.
for (int i = 0; i < cap; i++)
{
float a = i * 137.5f * Mathf.Deg2Rad; // irrational-ish spacing, no two ever coincide
var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a));
rig.interceptor.Absorb(Absorb(dir * 20f, -dir));
}
if (rig.interceptor.LiveCount != cap)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: expected {cap} live panels before exhaustion, got {rig.interceptor.LiveCount}.");
return false;
}
long before = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = cap; i < cap + 3; i++)
{
float a = i * 137.5f * Mathf.Deg2Rad;
var dir = new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a));
rig.interceptor.Absorb(Absorb(dir * 20f, -dir));
}
long after = System.GC.GetAllocatedBytesForCurrentThread();
if (rig.interceptor.LiveCount != cap)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: live count is {rig.interceptor.LiveCount} " +
$"after exceeding MaxLivePanels ({cap}); the cap did not hold.");
return false;
}
// Soft budget, not a hard zero: JIT/editor background allocation noise is real, but
// three GraphicsBuffer.SetData-only re-arms plus list bookkeeping should be a small,
// fixed number of bytes, not the kilobytes a Rebuild (or a per-hit array/list
// allocation regression) would cost.
long perStrike = (after - before) / 3;
if (perStrike > 4096)
{
Debug.LogError($"[ShieldPanelCut3Verify] exhaustion: ~{perStrike} B allocated per strike after " +
"the pool filled -- §3's 'no allocation at interception' looks violated.");
return false;
}
Debug.Log($"[ShieldPanelCut3Verify] exhaustion OK: live count capped at {cap}, ~{perStrike} B/strike once full.");
return true;
}
finally { DestroyRig(rig); }
}
// ==============================================================
static bool CheckNullGuardsDoNotThrow()
{
var go = new GameObject("Cut3Probe_NullGuard");
try
{
var interceptor = go.AddComponent<ShieldInterceptor>();
try
{
interceptor.Absorb(Absorb(Vector3.forward * 10, Vector3.back));
}
catch (System.Exception ex)
{
Debug.LogError($"[ShieldPanelCut3Verify] null-guard: Absorb with no Envelope/PanelPrototype threw {ex}.");
return false;
}
Debug.Log("[ShieldPanelCut3Verify] null-guard OK: Absorb with no Envelope/PanelPrototype did not throw.");
return true;
}
finally { UnityEngine.Object.DestroyImmediate(go); }
}
// ==============================================================
// The pooling fix this cut depends on: a panel returned to the pool and reused must NOT
// rebuild its tiling (§2.4's whole 123-2686x argument for prebuilding).
// ==============================================================
static bool CheckPoolReuseDoesNotRebuild()
{
var rig = BuildRig(maxLive: 4);
try
{
rig.interceptor.Absorb(Absorb(new Vector3(0, 0, 20), Vector3.back));
var panel = rig.interceptor.DebugPanelAt(0);
int rebuildsAfterSpawn = panel.DebugRebuildCount;
// ReturnToPool() is called on the INSTANCE (it hands itself to its own
// _originalPrototype's pool); Instantiate() must be called on the ORIGINAL prototype
// -- Prototype.Instantiate<T>'s free list lives on whichever object it's called on,
// and only the original's is ever populated by ReturnToPool.
panel.GetComponent<Prototype>().ReturnToPool();
var reused = rig.interceptor.PanelPrototype.Instantiate<ShieldPanel>();
if (!ReferenceEquals(reused, panel))
{
Debug.LogError("[ShieldPanelCut3Verify] pool-reuse: Instantiate() after ReturnToPool returned a " +
"different instance than the one just returned -- the free list isn't LIFO-of-one as expected.");
return false;
}
if (panel.DebugRebuildCount != rebuildsAfterSpawn)
{
Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: RebuildCount went {rebuildsAfterSpawn} -> " +
$"{panel.DebugRebuildCount} across a ReturnToPool/Instantiate cycle -- OnEnable is " +
"rebuilding on every reuse instead of only once.");
return false;
}
// The check above is necessary but not sufficient: SetActive(false)/(true) is not
// guaranteed to fire OnDisable/OnEnable synchronously within this same call stack in
// every context this probe can run in (observed directly -- the very first spawn's
// OnEnable does not run synchronously inside Object.Instantiate here, which is why
// ResetSim() is self-healing rather than relying on it). So drive OnEnable/OnDisable
// themselves, directly, to pin the rule those methods actually encode regardless of
// when/whether Unity's own SetActive plumbing calls them in a given context.
int rebuildsBeforeDirect = panel.DebugRebuildCount;
PanelOnDisable.Invoke(panel, null);
PanelOnEnable.Invoke(panel, null);
if (panel.DebugRebuildCount != rebuildsBeforeDirect || panel.DebugCellCount == 0)
{
Debug.LogError($"[ShieldPanelCut3Verify] pool-reuse: a direct OnDisable()/OnEnable() cycle " +
$"changed RebuildCount ({rebuildsBeforeDirect} -> {panel.DebugRebuildCount}) or " +
$"cleared the tiling (cellCount={panel.DebugCellCount}) -- OnEnable is rebuilding " +
"(or OnDisable is releasing) on every reuse instead of only once.");
return false;
}
Debug.Log("[ShieldPanelCut3Verify] pool-reuse OK: no Rebuild across a return/reuse cycle.");
return true;
}
finally { DestroyRig(rig); }
}
// ==============================================================
// D21: Penrose's achievable cell counts are phi-quantised, but must land within a small
// factor of Hex's at the same radius/cellSize, not an order of magnitude denser.
// ==============================================================
static bool CheckPenroseDensity()
{
const float cellSize = 0.35f;
// The ruling (R5/D21) fixes the MECHANISM -- nearest achievable edge length, not
// nearest achievable Hex-equivalent cell count -- and each Penrose substitution changes
// density by phi^2 (~2.618x), so landing within that step of Hex's count at an arbitrary
// radius/cellSize is already the honest ceiling for "nearest edge length", not a tuning
// knob. Measured after the fix (see the Cut 3 report): 2.12x-4.70x across r in
// {1.5,3,5,10} at cellSize 0.35, against the pre-fix 24x/8x order-of-magnitude bug this
// rule replaces. 5x is comfortably past that measured range and still an order of
// magnitude tighter than what D21 opened with.
const float smallFactor = 5f;
float[] radii = { 1.5f, 3f, 5f, 10f };
bool ok = true;
for (int i = 0; i < radii.Length; i++)
{
float r = radii[i];
var pSoup = Tilings.Generate(TilingKind.PenroseP3, r, cellSize, 1337);
var pMerged = TilingBuilder.MergeRhombs(pSoup);
var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337);
var hSoup = Tilings.Generate(TilingKind.Hex, r, cellSize, 1337);
var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f), clipRadius: r, seed: 1337);
float ratio = (float)pData.CellCount / hData.CellCount;
Debug.Log($"[ShieldPanelCut3Verify] D21 radius={r} cellSize={cellSize} " +
$"PenroseP3(merged) cells={pData.CellCount} Hex cells={hData.CellCount} ratio={ratio:F2}");
if (ratio > smallFactor || ratio < 1f / smallFactor)
{
Debug.LogError($"[ShieldPanelCut3Verify] D21 not within a small factor at radius {r}: " +
$"Penrose/Hex ratio {ratio:F2} is outside [1/{smallFactor:F0}, {smallFactor:F0}].");
ok = false;
}
}
if (ok) Debug.Log("[ShieldPanelCut3Verify] D21 OK: Penrose stays within a small factor of Hex at every radius.");
return ok;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eb4633f8820c41cba7ed651b672b2696

View file

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Mutation tests for docs/shield-panel-cut.md Cut 3 (the interceptor presenter and panel pool).
Assets/Scripts/Editor/ShieldPanelCut3Verify.cs is the batchmode probe Cut 3 added, for the same
reason Cut 1 and Cut 2 have one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp. Unlike
Cut 1/2's probes this one drives actual GraphicsBuffer dispatches (it steps ShieldPanel's private
Update() by reflection, since Cut 1 deliberately removed [ExecuteAlways] -- D13), so it must run
WITHOUT -nographics.
Each case here mutates the *real* source in one specific way the cut's own text says it fixed or
established as a rule, runs ShieldPanelCut3Verify.Run in Unity batchmode, asserts it goes red for
the reason the cut names, then restores the source exactly. A control case first proves the
unmutated tree passes, so a case that "fails" for the wrong reason (a broken harness, not a caught
mutation) cannot hide.
This is a separate script rather than an addition to mutation_tests_shield_panel_cut1.py or
_cut2.py (each of those owns its own cut's rules and touches only that cut's files) or
mutation_tests.py (the item-provenance cut's headless dotnet-test runner, with no path to a Unity
batchmode check). It touches only files Cut 3 owns: ShieldPanel.cs (the D17/D20/pooling fixes),
Tilings.cs (D21), and ShieldInterceptor.cs (reuse/exhaustion).
Run from the repo root: python tests/mutation_tests_shield_panel_cut3.py
Exit code 0 only if every case behaved as Cut 3's verification section demands.
"""
from __future__ import annotations
import subprocess
import sys
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"
SHIELD_FIELD = REPO_ROOT / "Assets" / "Shaders" / "Compute" / "ShieldField"
SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs"
TILINGS_CS = SHIELD_FIELD / "Tilings.cs"
SHIELD_INTERCEPTOR_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldInterceptor.cs"
def run_probe(log_path: Path) -> tuple[int, str]:
proc = subprocess.run(
[UNITY_EXE, "-batchmode", "-quit", "-projectPath", str(REPO_ROOT),
"-executeMethod", "Aetheria.EditorTools.ShieldPanelCut3Verify.Run",
"-logFile", str(log_path)],
timeout=600,
)
text = log_path.read_text(encoding="utf-8", errors="replace")
return proc.returncode, text
def replace_unique(path: Path, anchor: str, mutated: str) -> None:
original = path.read_text(encoding="utf-8")
count = original.count(anchor)
if count != 1:
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
def main() -> int:
scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut3"
scratch.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
original_panel = SHIELD_PANEL_CS.read_text(encoding="utf-8")
original_tilings = TILINGS_CS.read_text(encoding="utf-8")
original_interceptor = SHIELD_INTERCEPTOR_CS.read_text(encoding="utf-8")
def restore_all() -> None:
SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
TILINGS_CS.write_text(original_tilings, encoding="utf-8")
SHIELD_INTERCEPTOR_CS.write_text(original_interceptor, encoding="utf-8")
try:
# --- control: the real, unmutated tree must pass ---
print("\n=== control: unmutated tree ===")
code, text = run_probe(scratch / "control.log")
if code != 0 or "[ShieldPanelCut3Verify] PASS" not in text:
failures.append("control: expected exit 0 and PASS on the unmutated tree")
else:
print("control OK: exit 0, PASS")
# --- D20: reverting the backdated arm instant to the pre-Cut-3 hardcoded 0f does NOT
# swallow the spawn-frame hit itself (that's the seeded bState, D17's own fix, still doing
# its job for frame 1) -- it reopens a narrower, one-frame-later symptom: KUpdate resets
# growth back to 0 at the end of frame 1, so frame 2's own injection is what gets gated.
# This is exactly why the probe checks frame 2 separately from frame 1. ---
print("\n=== mutation: D20 regression (panelSpawnTime hardcoded back to 0f) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
'sim.SetFloat("_PanelSpawnTime", panelSpawnTime);',
'sim.SetFloat("_PanelSpawnTime", 0f);',
)
code, text = run_probe(scratch / "d17_spawntime.log")
caught = code != 0 and "D20 not fixed" in text
if not caught:
failures.append("D20 regression (spawn time): probe did not go red naming D20")
else:
print("OK: caught — probe named D20 not fixed")
finally:
restore_all()
# --- D17: the other half of the fix -- if growth is never seeded ahead of time (left at
# KInit's 0), the gate blocks the spawn-frame hit even with panelSpawnTime correct. ---
print("\n=== mutation: D17 regression (growth seed always 0) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
"growthSeed[i].growth = Mathf.Clamp01(\n"
" (growDuration - data.cells[i].spawnDelay) / Mathf.Max(growDuration, 1e-3f));",
"growthSeed[i].growth = 0f; // mutation_tests_shield_panel_cut3.py: reintroduce D17",
)
code, text = run_probe(scratch / "d17_seed.log")
caught = code != 0 and "D17 not fixed" in text
if not caught:
failures.append("D17 regression (growth seed): probe did not go red naming D17")
else:
print("OK: caught — probe named D17 not fixed")
finally:
restore_all()
# --- Pooling: reverting OnEnable/OnDisable to the pre-Cut-3 unconditional Rebuild/
# ReleaseAll must rebuild the tiling on every pooled reuse, which the pool exists to avoid
# (§2.4: 1.3-21ms vs 8-11us) -- CheckPoolReuseDoesNotRebuild is the rule that pins this. ---
print("\n=== mutation: pooling regression (OnEnable rebuilds every reuse again) ===")
try:
replace_unique(
SHIELD_PANEL_CS,
"void OnEnable() { if (data == null) Rebuild(); }\n"
" void OnDisable() { }\n"
" void OnDestroy() { ReleaseAll(); }",
"void OnEnable() { Rebuild(); } // mutation_tests_shield_panel_cut3.py: reintroduce the pre-Cut-3 rebuild-on-every-reuse bug\n"
" void OnDisable() { ReleaseAll(); }",
)
code, text = run_probe(scratch / "pooling.log")
caught = code != 0 and "OnEnable is rebuilding (or OnDisable is releasing)" in text
if not caught:
failures.append("pooling regression: probe did not go red naming the rebuild-on-reuse regression")
else:
print("OK: caught — probe named the rebuild-on-reuse regression")
finally:
restore_all()
# --- Reuse: a second hit inside a living panel must strike it, not spawn a new one. Force
# FindReusable to always miss and the rule collapses -- the reuse probe must see live count
# go to 2 on the second (nearby) strike instead of staying at 1. ---
print("\n=== mutation: reuse regression (FindReusable always misses) ===")
try:
replace_unique(
SHIELD_INTERCEPTOR_CS,
" int FindReusable(Vector3 point)\n {\n float threshold = PanelRadius * ReuseFraction;",
" int FindReusable(Vector3 point)\n {\n return -1; // mutation_tests_shield_panel_cut3.py: never reuse\n"
" float threshold = PanelRadius * ReuseFraction;",
)
code, text = run_probe(scratch / "reuse.log")
caught = code != 0 and "spawned a new panel" in text
if not caught:
failures.append("reuse regression: probe did not go red naming the spurious new panel")
else:
print("OK: caught — probe named the spurious new panel")
finally:
restore_all()
# --- Pool exhaustion: MaxLivePanels must cap the live set. Removing the eviction branch (so
# exhaustion always spawns a fresh instance instead of recycling the oldest) must blow the cap. ---
print("\n=== mutation: exhaustion regression (cap never enforced) ===")
try:
replace_unique(
SHIELD_INTERCEPTOR_CS,
" ShieldPanel panel;\n"
" Prototype panelProto;\n"
" if (_live.Count >= MaxLivePanels)\n",
" ShieldPanel panel;\n"
" Prototype panelProto;\n"
" if (false) // mutation_tests_shield_panel_cut3.py: never evict, blow the cap\n",
)
code, text = run_probe(scratch / "exhaustion.log")
caught = code != 0 and "the cap did not hold" in text
if not caught:
failures.append("exhaustion regression: probe did not go red naming the blown cap")
else:
print("OK: caught — probe named the blown cap")
finally:
restore_all()
# --- D21: reverting the Round-to-nearest fix to the pre-Cut-3 CeilToInt(...) + 1 formula
# must blow past the small-factor bound again (24x/8x order-of-magnitude, not 2-5x). ---
print("\n=== mutation: D21 regression (Penrose iteration count reverts to Ceil+1) ===")
try:
replace_unique(
TILINGS_CS,
" int iterations = Mathf.Clamp(\n"
" Mathf.RoundToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)),\n"
" 1, 12);",
" int iterations = Mathf.Clamp(\n"
" Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n"
" 1, 12); // mutation_tests_shield_panel_cut3.py: reintroduce D21's overshoot",
)
code, text = run_probe(scratch / "d21.log")
caught = code != 0 and "not within a small factor" in text
if not caught:
failures.append("D21 regression: probe did not go red naming the small-factor violation")
else:
print("OK: caught — probe named the small-factor violation")
finally:
restore_all()
finally:
# belt-and-suspenders: never leave a mutated file behind even if something above threw
restore_all()
print("\n=== summary ===")
if failures:
for f in failures:
print(f"FAIL: {f}")
return 1
print(f"All cases behaved as Cut 3's verification section demands. Logs: {scratch}")
return 0
if __name__ == "__main__":
sys.exit(main())