diff --git a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs
new file mode 100644
index 000000000..3cf82a7e2
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs
@@ -0,0 +1,466 @@
+using System.Reflection;
+using CultMath.UnityBridge;
+using ShieldField;
+using UnityEditor;
+using UnityEngine;
+
+namespace Aetheria.EditorTools
+{
+ ///
+ /// 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 <repo> -executeMethod
+ /// Aetheria.EditorTools.ShieldPanelCut3Verify.Run -logFile <path>
+ /// (no -quit while iterating: EditorApplication.Exit both reports the code and quits.)
+ ///
+ 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(
+ "Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
+ var shader = AssetDatabase.LoadAssetAtPath(
+ "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();
+
+ var protoGO = new GameObject("Cut3Probe_PanelPrototype");
+ var panel = protoGO.AddComponent();
+ // 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();
+
+ var interceptorGO = new GameObject("Cut3Probe_Interceptor");
+ var interceptor = interceptorGO.AddComponent();
+ 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(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();
+ 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's free list lives on whichever object it's called on,
+ // and only the original's is ever populated by ReturnToPool.
+ panel.GetComponent().ReturnToPool();
+ var reused = rig.interceptor.PanelPrototype.Instantiate();
+
+ 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;
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta
new file mode 100644
index 000000000..d3bb24fa4
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut3Verify.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: eb4633f8820c41cba7ed651b672b2696
diff --git a/tests/mutation_tests_shield_panel_cut3.py b/tests/mutation_tests_shield_panel_cut3.py
new file mode 100644
index 000000000..23c4f0675
--- /dev/null
+++ b/tests/mutation_tests_shield_panel_cut3.py
@@ -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())