diff --git a/.gitignore b/.gitignore
index 0524e8cb8..75b9cad4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,6 +100,7 @@ Vault/Aetheria/.obsidian/plugins/copilot/data.json
# come back.
.scratch-*
scratch-*
+tests/_scratch_*
.codex/
.codex-tmp/
.evepkg/
diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs
new file mode 100644
index 000000000..58e53a1ad
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Linq;
+using ShieldField;
+using UnityEditor;
+using UnityEditor.Rendering;
+using UnityEngine;
+
+namespace Aetheria.EditorTools
+{
+ ///
+ /// Batchmode probe for the shield-panel Cut 1 landing (docs/shield-panel-cut.md).
+ /// No NUnit coverage exists for these files (Tests.asmdef cannot see
+ /// Assembly-CSharp — see the map's Q7), so this is the check that stands in
+ /// for a unit test on two Cut 1 rules that a compile pass alone cannot prove:
+ ///
+ /// 1. The shader and compute shader carry zero compile errors on built-in.
+ /// 2. Penrose cell count actually scales with panelRadius (D18) instead of
+ /// being a fixed function of the (now-removed) authored iteration count.
+ ///
+ /// Run via: Unity -batchmode -projectPath <repo> -executeMethod
+ /// Aetheria.EditorTools.ShieldPanelCut1Verify.Run -quit -logFile <path>
+ /// Exits with a non-zero code if either rule is violated, so the log alone is
+ /// not the only signal — a CI runner can gate on the exit code too.
+ ///
+ public static class ShieldPanelCut1Verify
+ {
+ public static void Run()
+ {
+ bool ok = true;
+ ok &= CheckShaderMessages();
+ ok &= CheckComputeMessages();
+ ok &= CheckPenroseScalesWithRadius();
+
+ Debug.Log(ok
+ ? "[ShieldPanelCut1Verify] PASS"
+ : "[ShieldPanelCut1Verify] FAIL — see errors above");
+
+ EditorApplication.Exit(ok ? 0 : 1);
+ }
+
+ static bool CheckShaderMessages()
+ {
+ var shader = AssetDatabase.LoadAssetAtPath(
+ "Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
+ if (shader == null)
+ {
+ Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader not found.");
+ return false;
+ }
+
+ bool ok = true;
+
+ // The first query can observe the shader mid-compile and return a stale/empty
+ // message set; warm it up before trusting the result.
+ _ = ShaderUtil.GetShaderMessageCount(shader);
+ _ = ShaderUtil.ShaderHasError(shader);
+ var shaderMsgs = ShaderUtil.GetShaderMessages(shader);
+
+ foreach (var m in shaderMsgs)
+ {
+ if (m.severity == ShaderCompilerMessageSeverity.Error)
+ {
+ Debug.LogError($"[ShieldPanelCut1Verify] Shader error: {m.message} ({m.file}:{m.line})");
+ ok = false;
+ }
+ else
+ {
+ Debug.Log($"[ShieldPanelCut1Verify] Shader warning: {m.message} ({m.file}:{m.line})");
+ }
+ }
+
+ if (ShaderUtil.ShaderHasError(shader))
+ {
+ Debug.LogError("[ShieldPanelCut1Verify] ShieldPanel.shader has compile errors (ShaderHasError).");
+ ok = false;
+ }
+
+ Debug.Log($"[ShieldPanelCut1Verify] shader supported={!ShaderUtil.ShaderHasError(shader)} messages={shaderMsgs.Length}");
+ return ok;
+ }
+
+ static bool CheckComputeMessages()
+ {
+ var cs = AssetDatabase.LoadAssetAtPath(
+ "Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
+ if (cs == null)
+ {
+ Debug.LogError("[ShieldPanelCut1Verify] ShieldSim.compute not found.");
+ return false;
+ }
+
+ bool ok = true;
+ var msgs = ShaderUtil.GetComputeShaderMessages(cs);
+ foreach (var m in msgs)
+ {
+ if (m.severity == ShaderCompilerMessageSeverity.Error)
+ {
+ Debug.LogError($"[ShieldPanelCut1Verify] Compute shader error: {m.message} ({m.file}:{m.line})");
+ ok = false;
+ }
+ }
+ Debug.Log($"[ShieldPanelCut1Verify] compute messages={msgs.Length}, errors={msgs.Count(m => m.severity == ShaderCompilerMessageSeverity.Error)}");
+ return ok;
+ }
+
+ static bool CheckPenroseScalesWithRadius()
+ {
+ const float cellSize = 0.35f;
+ float[] radii = { 1.5f, 10f };
+ int[] penroseCounts = new int[radii.Length];
+ int[] hexCounts = new int[radii.Length];
+
+ for (int i = 0; i < radii.Length; i++)
+ {
+ var pSoup = Tilings.Generate(TilingKind.PenroseP3, radii[i], cellSize, 1337);
+ var pMerged = TilingBuilder.MergeRhombs(pSoup);
+ var pData = TilingBuilder.Build(pMerged, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
+ clipRadius: radii[i], seed: 1337);
+ penroseCounts[i] = pData.CellCount;
+
+ var hSoup = Tilings.Generate(TilingKind.Hex, radii[i], cellSize, 1337);
+ var hData = TilingBuilder.Build(hSoup, weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
+ clipRadius: radii[i], seed: 1337);
+ hexCounts[i] = hData.CellCount;
+
+ Debug.Log($"[ShieldPanelCut1Verify] radius={radii[i]} cellSize={cellSize} " +
+ $"PenroseP3(merged) cells={penroseCounts[i]} Hex cells={hexCounts[i]}");
+ }
+
+ bool ok = penroseCounts[0] != penroseCounts[1];
+ if (!ok)
+ Debug.LogError($"[ShieldPanelCut1Verify] D18 not fixed: Penrose cell count identical " +
+ $"({penroseCounts[0]}) at radius {radii[0]} and {radii[1]}.");
+ else if (penroseCounts[1] < penroseCounts[0])
+ Debug.LogError($"[ShieldPanelCut1Verify] D18 regression: Penrose cell count DECREASED " +
+ $"from {penroseCounts[0]} (r={radii[0]}) to {penroseCounts[1]} (r={radii[1]}).");
+ else
+ Debug.Log("[ShieldPanelCut1Verify] D18 fixed: Penrose cell count increases with panelRadius.");
+
+ return ok && penroseCounts[1] > penroseCounts[0];
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta
new file mode 100644
index 000000000..a09fb26c3
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut1Verify.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 22124e7322748c84a80cc32b7d601bce
\ No newline at end of file
diff --git a/tests/mutation_tests_shield_panel_cut1.py b/tests/mutation_tests_shield_panel_cut1.py
new file mode 100644
index 000000000..00128c413
--- /dev/null
+++ b/tests/mutation_tests_shield_panel_cut1.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Mutation tests for docs/shield-panel-cut.md Cut 1 (the shield-panel source landing).
+
+Assets/Scripts/Editor/ShieldPanelCut1Verify.cs is the batchmode probe Cut 1 added because no
+NUnit coverage can reach these files: Tests.asmdef cannot see Assembly-CSharp (the map's Q7), and
+the rules it defends are Unity-native (shader/compute compile errors, cell counts a compute
+kernel would read back). A probe nobody can break is a probe nobody can trust, so each case here
+mutates the *real* source in one specific way the cut's own text says it fixed, runs
+ShieldPanelCut1Verify.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 tests/mutation_tests.py (the item-provenance
+cut's source-anchor-mutation-plus-`dotnet test` runner, with no path to driving a Unity batchmode
+check) or tests/mutation_tests_addressables_cut.py (EngineAssetCheck's own harness, same reason
+this one exists, different rule). It touches only files this cut owns.
+
+Run from the repo root: python tests/mutation_tests_shield_panel_cut1.py
+Exit code 0 only if every case behaved as Cut 1'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"
+TILINGS_CS = SHIELD_FIELD / "Tilings.cs"
+SHIELD_COMMON_HLSL = SHIELD_FIELD / "ShieldCommon.hlsl"
+SHIELD_PANEL_CS = SHIELD_FIELD / "ShieldPanel.cs"
+
+
+def run_probe(log_path: Path) -> tuple[int, str]:
+ proc = subprocess.run(
+ [UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT),
+ "-executeMethod", "Aetheria.EditorTools.ShieldPanelCut1Verify.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) -> str:
+ 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")
+ return original
+
+
+def main() -> int:
+ scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut1"
+ scratch.mkdir(parents=True, exist_ok=True)
+ failures: list[str] = []
+
+ original_tilings = TILINGS_CS.read_text(encoding="utf-8")
+ original_common = SHIELD_COMMON_HLSL.read_text(encoding="utf-8")
+ original_panel = SHIELD_PANEL_CS.read_text(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 "[ShieldPanelCut1Verify] PASS" not in text:
+ failures.append("control: expected exit 0 and PASS on the unmutated tree")
+ else:
+ print("control OK: exit 0, PASS")
+
+ # --- D18: Penrose must stop expressing panelRadius if the derivation is reverted ---
+ print("\n=== mutation: D18 regression (Penrose iterations decoupled from radius again) ===")
+ anchor = (
+ " int iterations = Mathf.Clamp(\n"
+ " Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,\n"
+ " 1, 12);\n"
+ " return Penrose(radius, iterations);"
+ )
+ mutated = (
+ " // mutation_tests_shield_panel_cut1.py: reintroduce the D18 bug — a fixed\n"
+ " // iteration count, independent of radius or cellSize.\n"
+ " return Penrose(radius, 5);"
+ )
+ try:
+ replace_unique(TILINGS_CS, anchor, mutated)
+ code, text = run_probe(scratch / "d18.log")
+ if code == 0 or "D18 not fixed" not in text:
+ failures.append("D18 regression: probe did not go red naming the D18 regression")
+ else:
+ print("OK: caught — probe exited non-zero and named the D18 regression")
+ finally:
+ TILINGS_CS.write_text(original_tilings, encoding="utf-8")
+
+ # --- D4: `centroid` is a reserved word in the shader/compute compiler; reintroducing it as
+ # a CellStatic field name must produce a compile error somewhere it is used (ShieldCommon.hlsl
+ # is shared by both ShieldSim.compute and ShieldPanel.shader) ---
+ print("\n=== mutation: D4 regression (centroid field name reintroduced) ===")
+ anchor = "struct CellStatic\n{\n float2 center;"
+ mutated = "struct CellStatic\n{\n float2 centroid;"
+ try:
+ replace_unique(SHIELD_COMMON_HLSL, anchor, mutated)
+ # Every use of `.center` on a CellStatic instance across the two GPU-side consumers
+ # must track the field rename for this mutation to compile-check the reserved name
+ # (not just silently fail on a dangling reference to a field that no longer exists
+ # under that name — the point is the *reserved word*, not a typo).
+ sim_cs_path = SHIELD_FIELD / "ShieldSim.compute"
+ shader_path = SHIELD_FIELD / "ShieldPanel.shader"
+ orig_sim = sim_cs_path.read_text(encoding="utf-8")
+ orig_shader = shader_path.read_text(encoding="utf-8")
+ sim_cs_path.write_text(orig_sim.replace("c.center", "c.centroid"), encoding="utf-8")
+ shader_path.write_text(
+ orig_shader.replace("x.c.center", "x.c.centroid").replace("nc.center", "nc.centroid"),
+ encoding="utf-8",
+ )
+ try:
+ code, text = run_probe(scratch / "d4.log")
+ caught = ("Compute shader error" in text or "Shader error" in text) and "centroid" in text
+ if code == 0 or not caught:
+ failures.append("D4 regression: probe did not go red naming the reserved 'centroid' token")
+ else:
+ print("OK: caught — probe exited non-zero and named the reserved 'centroid' token")
+ finally:
+ sim_cs_path.write_text(orig_sim, encoding="utf-8")
+ shader_path.write_text(orig_shader, encoding="utf-8")
+ finally:
+ SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8")
+
+ # --- D2/D5: RenderParams.shaderPass does not exist in 6000.3.24f1; reintroducing it must
+ # fail the C# compile, not silently resurrect the deleted per-pass draw path ---
+ print("\n=== mutation: D2 regression (RenderParams.shaderPass reintroduced) ===")
+ anchor = " Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }"
+ mutated = " rp.shaderPass = 0;\n Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);\n }"
+ try:
+ replace_unique(SHIELD_PANEL_CS, anchor, mutated)
+ code, text = run_probe(scratch / "d2.log")
+ if code == 0:
+ failures.append("D2 regression: batchmode exited 0 despite a nonexistent RenderParams.shaderPass member")
+ else:
+ print("OK: caught — batchmode failed to compile with shaderPass reintroduced")
+ finally:
+ SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
+
+ finally:
+ # belt-and-suspenders: never leave a mutated file behind even if something above threw
+ TILINGS_CS.write_text(original_tilings, encoding="utf-8")
+ SHIELD_COMMON_HLSL.write_text(original_common, encoding="utf-8")
+ SHIELD_PANEL_CS.write_text(original_panel, encoding="utf-8")
+
+ print("\n=== summary ===")
+ if failures:
+ for f in failures:
+ print(f"FAIL: {f}")
+ return 1
+ print(f"All cases behaved as Cut 1's verification section demands. Logs: {scratch}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())