using System.Linq; using System.Reflection; using ShieldField; using UnityEditor; using UnityEngine; namespace Aetheria.EditorTools { /// /// Batchmode probe for the shield-panel Cut 5 landing (docs/shield-panel-cut.md): R7 (the rim /// is held, not free — the `openW` ghost term is gone), D9 (v was unclamped and pinned u in /// compression above ~energy 60), D10 (KFracture ran once per frame but integrated with the /// substep dt), and the tuning pass that makes the design's targets reachable. /// /// Pins BEHAVIOUR RULES, not today's numbers, per the brief: monotonic peak tension against /// energy, a solid hit breaks / a weak hit does not, repeated weak hits erode temper into an /// eventual break, breaks cluster near the impact rather than the rim (R7's whole point), and /// a broken cell's neighbours are more likely to break than distant cells (the dicing cascade). /// /// Same reason as Cut 3's probe: this drives ShieldPanel's actual compute dispatches, so it /// needs a real graphics device -- run WITHOUT -nographics, or every kernel lookup fails with /// "Kernel 'KInit' not found", which reads like a broken shader and is not one. /// /// Run via: Unity -batchmode -projectPath <repo> -executeMethod /// Aetheria.EditorTools.ShieldPanelCut5Verify.Run -quit -logFile <path> /// public static class ShieldPanelCut5Verify { const string Tag = "[ShieldPanelCut5Verify]"; static readonly MethodInfo PanelUpdate = typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance); public static void Run() { if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null) { Debug.LogError($"{Tag} FAIL: no graphics device in this session. Rerun batchmode " + "without -nographics (see ShieldPanelCut3Verify's note)."); EditorApplication.Exit(1); return; } bool ok = true; ok &= CheckMonotonicTensionSweep(); ok &= CheckSolidHitBreaksWeakDoesNot(); ok &= CheckRepeatedWeakHitsErodeIntoBreak(); ok &= CheckRepeatedWeakHitsBreakWhereOneDoesNot(); ok &= CheckBreaksClusterNearImpactNotRim(); ok &= CheckNeighboursMoreLikelyToBreak(); Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above"); EditorApplication.Exit(ok ? 0 : 1); } // ============================================================== static (GameObject go, ShieldPanel panel) BuildPanel(float panelRadius = 3f, float cellSize = 0.4f) { var sim = AssetDatabase.LoadAssetAtPath( "Assets/Shaders/Compute/ShieldField/ShieldSim.compute"); var shader = AssetDatabase.LoadAssetAtPath( "Assets/Shaders/Compute/ShieldField/ShieldPanel.shader"); var go = new GameObject("Cut5Probe_Panel"); var panel = go.AddComponent(); panel.tiling = TilingKind.Hex; panel.panelRadius = panelRadius; panel.cellSize = cellSize; panel.sim = sim; panel.panelShader = shader; panel.Rebuild(); return (go, panel); } static void Step(ShieldPanel panel, int n = 1) { for (int i = 0; i < n; i++) PanelUpdate.Invoke(panel, null); } static void Strike(ShieldPanel panel, Vector2 localPos, float energy) => panel.Hit(panel.transform.position + new Vector3(localPos.x, localPos.y, 0), Vector3.back, energy); // Mirrors ShieldField.CellState's layout, kept local exactly as Cut 3's probe does (a // future field reorder in the production struct is this probe's business to catch). [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; } static CellStateRaw[] ReadState(ShieldPanel panel) { var raw = new CellStateRaw[panel.DebugCellCount]; panel.DebugStateBuffer.GetData(raw); return raw; } static Vector2[] Centers(ShieldPanel panel) { var c = new Vector2[panel.DebugCellCount]; for (int i = 0; i < c.Length; i++) c[i] = panel.DebugCellCenter(i); return c; } // ============================================================== // D9: peak tension must rise monotonically (within noise) with energy over the authored // range, never collapse the way the unclamped-v defect measured past ~energy 60. // ============================================================== static bool CheckMonotonicTensionSweep() { float[] energies = { 5f, 10f, 20f, 30f, 45f, 60f, 80f, 100f, 140f, 180f, 240f }; float[] peak = new float[energies.Length]; for (int i = 0; i < energies.Length; i++) { var (go, panel) = BuildPanel(); try { // Isolate D9 (the wave's velocity clamp) from the fracture cascade: once cells // start breaking, KFracture's neighbour feedback injects extra tension that has // nothing to do with the wave integrator, which would confound this specific // check. breakAt = 2 (above damage's [0,1] range) means nothing can ever cross // it, so this measures pure elastic peak tension against energy. panel.breakAt = 2f; Strike(panel, Vector2.zero, energies[i]); Step(panel, 150); var st = ReadState(panel); peak[i] = st.Max(s => s.peakTension); int brokenDbg = st.Count(s => s.breakTime >= 0f); if (brokenDbg > 0) Debug.LogWarning($"{Tag} DIAG sweep E={energies[i]}: {brokenDbg} broken despite breakAt override!"); } finally { UnityEngine.Object.DestroyImmediate(go); } } string trace = string.Join(", ", energies.Zip(peak, (e, p) => $"E={e:F0}->{p:F3}")); Debug.Log($"{Tag} tension sweep: {trace}"); // This is a clamped nonlinear wave PDE sampled after a fixed settling window, not a // strictly monotone function -- point-to-point wobble of a few tens of percent is // expected numerical texture, not the defect. The defect this guards against has a // specific, much larger shape: the map measured tension COLLAPSING above energy ~60, // i.e. falling back toward the low-energy baseline once v pinned the field in // compression. So the rule is a running-max floor: no sample may fall below half of // the best result seen at any lower energy so far. That tolerates ordinary wobble // (worst observed in tuning: ~55% of the running max) while still catching a real // collapse (which drops toward the near-zero baseline, not to 50-99% of it). bool ok = true; float runningMax = 0f; for (int i = 0; i < peak.Length; i++) { if (i > 0 && peak[i] < runningMax * 0.5f) { Debug.LogError($"{Tag} D9 not fixed: tension collapsed at energy {energies[i]:F0} " + $"(peak {peak[i]:F3} vs running max {runningMax:F3})."); ok = false; } runningMax = Mathf.Max(runningMax, peak[i]); } float overallMax = runningMax; // Negative signature from the map: no energy in range may leave the field completely // flat (maxPeakTension == 0), which would mean nothing is reaching the material at all. if (overallMax <= 1e-4f) { Debug.LogError($"{Tag} D9 negative check failed: no energy in the sweep produced any " + "measurable peak tension (the saturation-to-zero signature)."); ok = false; } if (ok) Debug.Log($"{Tag} D9 OK: tension never falls below half its running max across the " + $"sweep (peak {overallMax:F3}) -- no collapse."); return ok; } // ============================================================== static bool CheckSolidHitBreaksWeakDoesNot() { bool ok = true; var (weakGo, weakPanel) = BuildPanel(); try { Strike(weakPanel, Vector2.zero, 8f); Step(weakPanel, 150); int broken = ReadState(weakPanel).Count(s => s.breakTime >= 0f); if (broken != 0) { Debug.LogError($"{Tag} weak hit broke {broken} cells; expected 0."); ok = false; } else Debug.Log($"{Tag} weak hit OK: 0 cells broken."); } finally { UnityEngine.Object.DestroyImmediate(weakGo); } var (strongGo, strongPanel) = BuildPanel(); try { int total = strongPanel.DebugCellCount; Strike(strongPanel, Vector2.zero, 300f); Step(strongPanel, 200); int broken = ReadState(strongPanel).Count(s => s.breakTime >= 0f); float frac = (float)broken / total; if (broken < 2) { Debug.LogError($"{Tag} solid hit broke {broken} of {total} cells; expected a local " + "cluster (>=2), not a single cell or nothing."); ok = false; } else if (frac > 0.6f) { Debug.LogError($"{Tag} solid hit broke {broken}/{total} ({frac:P0}) — that's the whole panel, " + "not a local shatter; tuning has overshot."); ok = false; } else Debug.Log($"{Tag} solid hit OK: broke {broken}/{total} ({frac:P0})."); } finally { UnityEngine.Object.DestroyImmediate(strongGo); } return ok; } // ============================================================== // Temper erosion: repeated sub-critical hits must measurably wear the temper down toward // the material's critical margin ("this is the multi-hit budget" -- ShieldSim.compute). // // NOTE ON SCOPE: the map's own text frames "a later hit breaks through where the first // didn't" as an OPERATOR check (Cut 5's own Verification list marks it "operator:", not // "test:"), and this probe tried to also pin it as an automated rule. It could not: a // closing hit landed AFTER several prior weak hits on the SAME live panel instance // measured a materially lower peak tension than the identical energy landed on a FRESH // panel (e.g. energy 20 broke a fresh panel's control only one increment away at energy // 21, yet produced no measurable rise over the weak hits' own peak when landed after three // prior weak hits on one panel) -- a real, reproducible interaction between repeated // strikes on one live instance that this cut did not chase to ground. Flagged for the // operator's own repeated-hit play check and for a follow-up investigation; not silently // asserted as passing. What IS pinned here, reliably: erosion itself measurably progresses. // ============================================================== // The operator-facing payoff Cut 5's own report could not pin (see the scope note on // CheckRepeatedWeakHitsErodeIntoBreak above): N weak hits on one LIVE panel eventually // break a cell where one weak hit -- even given the same total time to sit and do nothing // -- does not. energy 15 is calibrated (not energy 8, which erodes temper to 0 but whose // peak tension of ~0.14 never reaches tensileStrength=0.2, so no amount of repetition would // ever cross the break threshold -- a genuine dead end at that energy, not a step-budget // problem): at 15, single-hit peak tension (~0.26) sits below temperInit (0.55) so a cold // panel absorbs it elastically, but once erosion (Cut 3's reuse mechanism) drops temper far // enough, that same ~0.26 peak clears net >= tensileStrength and breaks. static bool CheckRepeatedWeakHitsBreakWhereOneDoesNot() { const float weakEnergy = 15f; const int settleSteps = 120; const int maxHits = 6; // generous headroom over the 2 hits actually needed at this tuning // Control first: one hit, then sit idle for the SAME total step budget the multi-hit // run below could consume (maxHits * settleSteps) -- rules out "it just needed more // time to ring out", which would be a timing artifact, not the reuse/erosion payoff. var (controlGo, controlPanel) = BuildPanel(); int controlBroken; float controlPeak; try { Strike(controlPanel, Vector2.zero, weakEnergy); Step(controlPanel, settleSteps * maxHits); var raw = ReadState(controlPanel); controlBroken = raw.Count(s => s.breakTime >= 0f); controlPeak = raw.Max(s => s.peakTension); } finally { UnityEngine.Object.DestroyImmediate(controlGo); } if (controlBroken > 0) { Debug.LogError($"{Tag} multi-hit control: a single energy-{weakEnergy:F0} hit broke " + $"{controlBroken} cells even after {settleSteps * maxHits} idle steps -- " + "not weak enough to be a control."); return false; } // Repeated hits on ONE live panel, same energy, same strike point. var (go, panel) = BuildPanel(); try { int hitsToBreak = -1; int broken = 0; float lastPeak = 0f; for (int hit = 1; hit <= maxHits; hit++) { Strike(panel, Vector2.zero, weakEnergy); Step(panel, settleSteps); var raw = ReadState(panel); broken = raw.Count(s => s.breakTime >= 0f); lastPeak = raw.Max(s => s.peakTension); if (broken > 0) { hitsToBreak = hit; break; } } Debug.Log($"{Tag} multi-hit reuse: control (1 hit + {settleSteps * maxHits} idle steps) " + $"broke 0 cells (peak tension {controlPeak:F3}); on a live reused panel the " + $"same energy-{weakEnergy:F0} hit broke through on hit {hitsToBreak} " + $"(peak tension {lastPeak:F3}, {broken} cell(s) broken)."); if (hitsToBreak < 0) { Debug.LogError($"{Tag} multi-hit reuse not evident: {maxHits} repeated energy-" + $"{weakEnergy:F0} hits on one live panel never broke a cell (control " + "also did not break, so this is not merely 'nothing breaks at this " + "energy' -- the reuse/erosion payoff did not materialise)."); return false; } if (hitsToBreak < 2) { Debug.LogError($"{Tag} multi-hit reuse not evident: the live panel broke on hit 1, " + "same as a cold panel would -- this doesn't demonstrate reuse eroding " + "toward a break, it demonstrates the energy alone already breaks fresh."); return false; } Debug.Log($"{Tag} multi-hit reuse OK: hit 1 alone (and a cold panel given equal idle time) " + $"do not break; hit {hitsToBreak} on the same live, reused panel does -- the Cut " + "3 reuse rule and the temper mechanism together produce the operator payoff."); return true; } finally { UnityEngine.Object.DestroyImmediate(go); } } // ============================================================== static bool CheckRepeatedWeakHitsErodeIntoBreak() { const int erosionHits = 6; const float weakEnergy = 8f; // confirmed elsewhere: never breaks a fresh panel alone var (go, panel) = BuildPanel(); try { float temperInit = panel.temperInit; float temperAfterOne = float.NaN; for (int hit = 1; hit <= erosionHits; hit++) { Strike(panel, Vector2.zero, weakEnergy); Step(panel, 120); // let each weak hit's wave settle before the next if (hit == 1) temperAfterOne = ReadState(panel).Min(s => s.temper); } var raw = ReadState(panel); int broken = raw.Count(s => s.breakTime >= 0f); float temperFinal = raw.Min(s => s.temper); Debug.Log($"{Tag} temper erosion: min temper {temperInit:F3} -> {temperAfterOne:F3} " + $"(after 1 hit) -> {temperFinal:F3} (after {erosionHits} hits); {broken} cells " + "broken along the way (not required by this rule -- see the scope note above)."); if (broken > 0) { Debug.LogError($"{Tag} temper erosion: {broken} cells broke from weak hits alone -- " + "energy 8 is not actually sub-critical at the current tuning."); return false; } // The erosion rule itself: repeated sub-critical hits must cut temper by more than // half of its starting budget -- a real, substantial erosion, not numerical noise. if (temperFinal > temperInit * 0.5f) { Debug.LogError($"{Tag} temper erosion not evident: {erosionHits} weak hits only " + $"eroded temper from {temperInit:F3} to {temperFinal:F3}."); return false; } // And it must be MONOTONE progress, not a one-shot drop that then does nothing -- // hit 1 alone must not already have done everything hit 6 achieves. if (temperFinal >= temperAfterOne - 1e-4f) { Debug.LogError($"{Tag} temper erosion not evident: temper after 1 hit ({temperAfterOne:F3}) " + $"is not measurably above temper after {erosionHits} hits ({temperFinal:F3}) -- " + "erosion isn't accumulating across repeated hits."); return false; } Debug.Log($"{Tag} temper erosion OK: {erosionHits} weak hits eroded temper " + $"{temperInit:F3} -> {temperFinal:F3} without breaking anything."); return true; } finally { UnityEngine.Object.DestroyImmediate(go); } } // ============================================================== // R7: strike an interior point that is neither the panel centre nor the rim, and confirm // breaks cluster tightly around THAT point rather than smearing toward the boundary. Under // the old free-rim term, a strong hit's compressive wave reflects off the whole boundary as // tension, which (given enough travel time) preferentially loads the rim rather than the // impact -- exactly the "shatters only near the rim" symptom the operator reported. // ============================================================== static bool CheckBreaksClusterNearImpactNotRim() { // Same panelRadius/cellSize as the solid-hit check, which is the configuration this // cut's tuning was actually calibrated against (a bigger panel at the same cellSize // pushes the growth wavefront's spawnDelay timing out for cells far from centre and // starves the cascade of frames to work with inside this probe's fixed step budget). const float panelRadius = 3f; var impact = new Vector2(0.9f, 0.2f); // interior, well off both centre and rim (|impact| ~= 0.92 vs rim 3) var (go, panel) = BuildPanel(panelRadius: panelRadius, cellSize: 0.4f); try { Strike(panel, impact, 300f); Step(panel, 200); var state = ReadState(panel); var centers = Centers(panel); int total = state.Length; var brokenDist = new System.Collections.Generic.List(); for (int i = 0; i < total; i++) if (state[i].breakTime >= 0f) brokenDist.Add(Vector2.Distance(centers[i], impact)); if (brokenDist.Count == 0) { Debug.LogError($"{Tag} R7 cluster check: the strong off-centre hit broke nothing; " + "cannot evaluate clustering."); return false; } float meanAll = 0f; for (int i = 0; i < total; i++) meanAll += Vector2.Distance(centers[i], impact); meanAll /= total; float meanBroken = brokenDist.Average(); float maxBroken = brokenDist.Max(); Debug.Log($"{Tag} R7 cluster: {brokenDist.Count}/{total} broken, meanDistToImpact(broken)=" + $"{meanBroken:F3}, meanDistToImpact(all)={meanAll:F3}, maxDistToImpact(broken)=" + $"{maxBroken:F3}, panelRadius={panelRadius}."); bool ok = true; if (meanBroken > meanAll * 0.35f) { Debug.LogError($"{Tag} R7 not held: broken cells' mean distance to impact ({meanBroken:F3}) " + $"is not tightly clustered against the panel-wide mean ({meanAll:F3}) — " + "breaks are spreading rather than localising at the hit."); ok = false; } // Distance from impact to the nearest rim point is panelRadius - |impact|; breaks // reaching anywhere near that distance means the rim, not the impact, is spalling. float distToRim = panelRadius - impact.magnitude; if (maxBroken > distToRim * 0.6f) { Debug.LogError($"{Tag} R7 not held: a broken cell sits {maxBroken:F3} from the impact, " + $"{maxBroken / distToRim:P0} of the way to the rim ({distToRim:F3} away) — " + "the rim is still acting as a stress source."); ok = false; } if (ok) Debug.Log($"{Tag} R7 OK: breaks cluster at the impact, not the rim."); return ok; } finally { UnityEngine.Object.DestroyImmediate(go); } } // ============================================================== // Dicing cascade: a broken cell dumps its released energy into its dual-graph neighbours // (KFracture's feedback loop), so a neighbour of a broken cell should be measurably more // likely to also be broken than a cell far from any break. Approximate "dual-graph // neighbour" geometrically (nearest cell centres) since the probe has no access to the // private edge list -- for a hex tiling that is the same adjacency the sim actually uses. // ============================================================== static bool CheckNeighboursMoreLikelyToBreak() { var (go, panel) = BuildPanel(panelRadius: 3f, cellSize: 0.4f); try { Strike(panel, Vector2.zero, 150f); Step(panel, 200); var state = ReadState(panel); var centers = Centers(panel); int n = state.Length; bool[] broken = new bool[n]; for (int i = 0; i < n; i++) broken[i] = state[i].breakTime >= 0f; int brokenCount = broken.Count(b => b); if (brokenCount == 0 || brokenCount == n) { Debug.LogError($"{Tag} cascade check: {brokenCount}/{n} broken — need a partial " + "break to say anything about neighbours vs. distant cells."); return false; } // Self-calibrate "adjacent" from the tiling's OWN geometry instead of guessing a // multiple of cellSize (which does not equal true hex centroid spacing): take the // median nearest-OTHER-cell distance across the whole panel as the typical // dual-graph edge length, then classify relative to that. float[] nearestOther = new float[n]; for (int i = 0; i < n; i++) { float best = float.MaxValue; for (int j = 0; j < n; j++) { if (j == i) continue; best = Mathf.Min(best, Vector2.Distance(centers[i], centers[j])); } nearestOther[i] = best; } float typicalSpacing = nearestOther.OrderBy(d => d).ElementAt(n / 2); float nbrRadius = typicalSpacing * 1.2f; float farRadius = typicalSpacing * 1.7f; Debug.Log($"{Tag} cascade calibration: typical spacing={typicalSpacing:F3}, " + $"nbrRadius={nbrRadius:F3}, farRadius={farRadius:F3}, broken={brokenCount}/{n}."); // For every cell, find its distance to the nearest OTHER broken cell, then bucket // it as "near a break" or "distant from every break" and see which bucket breaks // more often. A cell counts toward its own bucket whether or not it is itself // broken -- that's the point: does proximity to a break predict being broken. int nbrTotal = 0, nbrBroken = 0, farTotal = 0, farBroken = 0; for (int i = 0; i < n; i++) { float nearestOtherBrokenDist = float.MaxValue; for (int j = 0; j < n; j++) { if (j == i || !broken[j]) continue; nearestOtherBrokenDist = Mathf.Min(nearestOtherBrokenDist, Vector2.Distance(centers[i], centers[j])); } if (nearestOtherBrokenDist <= nbrRadius) { nbrTotal++; if (broken[i]) nbrBroken++; } else if (nearestOtherBrokenDist >= farRadius) { farTotal++; if (broken[i]) farBroken++; } } if (nbrTotal == 0 || farTotal == 0) { Debug.LogError($"{Tag} cascade check: not enough cells in one bucket to compare " + $"(neighbours={nbrTotal}, distant={farTotal})."); return false; } float nbrRate = (float)nbrBroken / nbrTotal; float farRate = (float)farBroken / farTotal; Debug.Log($"{Tag} cascade: neighbour-of-a-break rate {nbrBroken}/{nbrTotal}={nbrRate:P1}, " + $"distant rate {farBroken}/{farTotal}={farRate:P1}."); if (nbrRate <= farRate) { Debug.LogError($"{Tag} cascade not evident: neighbours of a break ({nbrRate:P1}) are not " + $"more likely to be broken than distant cells ({farRate:P1})."); return false; } Debug.Log($"{Tag} cascade OK: neighbours break at {nbrRate:P1} vs {farRate:P1} for distant cells."); return true; } finally { UnityEngine.Object.DestroyImmediate(go); } } } }