# Shield Panel: Hard-Light Interceptor Cut Map Date: 2026-09-18 (revised; supersedes the flat-panel map committed at `1fdf8151`) Status: Imagination pass, cut map. Nothing here has landed. Repo anchors are against `codex/item-provenance` HEAD `482229d1` — the Addressables cuts (`67cbf34b`, `482229d1`) landed after the first draft, so every `file:line` below was re-checked against the current tree. The six incoming sources are still not in the repo; they are anchored as `files/:` against the operator's drop at `/files/`. Claims marked **(probe)** were measured in Unity 6000.3.24f1, Direct3D12, `srp=builtin`, batchmode with graphics, in a throwaway project holding only the six files and a ported shader. Nothing was built in the Aetheria tree and no Unity session was opened against it. The probe project is scratch and goes with the session; §1 and §2 record what it proved so nobody has to re-run it. ## 0. Operator rulings, 2026-09-18 These close four of the previous pass's forks and change the shape of the thing. The old design — one permanent flat disc standing in front of the hull — is history; see §9. **R1 (was Q2) — alongside, not instead.** "It's a different way of doing shields." The panel effect runs next to `FieldDriver`'s absorb visual, not in place of it. Both subscribe to the same `AbsorbEvent` stream; neither owns the other. The previous recommendation ("A for the first landing, then B as a deliberate deletion") is withdrawn — B is not coming. **R2 — the shield is an interceptor.** "It's a shield that intercepts hits by spawning those hard-light panels. I'd set the normal of the panels to the surface of the collision ellipsoid." So a panel is **short-lived and per-interception**: it does not exist until something is intercepted, it is posed at the intercept point with its normal along the ellipsoid surface normal there, and it dies. Not a permanent panel. Not a wrapped shell. Its **size comes from the shield item, not the hull** — the hull decides where the panel appears, the item decides how big it is. **R3 — every shield effect is a drop-in behavioural replacement.** A shield presentation takes absorb events, presents them, and decides nothing. The contract is behavioural: coverage is expressed **through the envelope**, not through literal geometry. A presentation is allowed to be a wrapped shell, a flat plate, or a swarm of spawned patches, as long as it answers the same events over the same envelope. **R4 — the ellipsoid envelope is standardised and authored on the hull prefab.** "The field shield already has the ellipsoid, we'd just be standardizing that. Ship prefabs would need to define their ellipsoid envelope." **R5 (was Q3) — fix Penrose in this cut.** Operator: "definitely fix penrose". D18 (`Penrose` ignores `panelRadius`) is a Cut 1 defect, not a follow-up: R2 makes panel size the item's to set, so a tiling that cannot be sized cannot ship. Hex remains the fallback while the fix is verified, and D19 (TriHex yields no dual edges) and D7 (Voronoi is 15 s at r10) stay recorded, not fixed. **R6 (was Q2) — the envelope is derived from the existing transform**, not authored as centre and radii. Operator: "derived; the simulation doesn't even use this collision, the hits we end up showing will be 'fake'". Two consequences worth holding on to: - The envelope has no second copy to drift from the `Shield` collider, which is the whole point of collapsing the two implicit ellipsoids. - **Panel placement is cosmetic.** The simulation does not resolve hits against this geometry; fire control rolls decide what happens (`docs/three-gates-scope.md`). The intercept point is where the presentation says the shot arrived, and nothing downstream may read it back as truth. Q1 (does the first landing have to fracture) is **still open** and still first; see §8. > **Cut 1 landed 2026-09-18** (`a935b965` sources+port+defects, `ff8b2ff7` probe and its > mutation harness). Batchmode: 0 C#, shader and compute errors; only D15's uninitialised- > variable warning survives. D1, D3, D4, D6, D12, D18 fixed; D2/D5 collapsed the draw to one > call; D7 and D19 recorded; D9/D10 to Cut 5, D17/D20 to Cut 3. The fix for D18 retired > `penroseIterations` and `subdivisions`. A repo-specific trap the map missed: > `UI/ContextMenu.cs` shadows `UnityEngine.ContextMenu`, so the attribute needs qualifying. > > **Open, found in Cut 1's own numbers (D21):** Penrose scales with radius now, but not with > `cellSize` the way Hex does — at r1.5/cellSize 0.35 it yields 460 cells where Hex yields 19, > and 8050 at r10 against Hex's 979. Iteration count is quantised (each substitution divides > edge length by phi), so a panel can only land near the requested density, and it currently > lands an order of magnitude denser. Cut 3 sizes panels from the item (R2) and pools them > (§2.4), so settle this before pooling: either the count is wrong, or `cellSize` means > something different for a quasiperiodic tiling and the item's knob must say which. > **Cut 2 landed 2026-09-18** (`a454a5a7`, fallback fix `7848a8a8`). One owner, > `Gameplay/ShieldEnvelope.cs`; the hand-rolled projections in `FieldDriver` and > `ShieldManager` are gone. Radii are measured from the carrier's collider mesh, not assumed: > the map's `lossyScale * 0.5` guess was wrong, and Shield.prefab's icosphere collider is not > even isotropic. `SurfaceNormal` scales the gradient by the inverse of `lossyScale` (a normal > is a covector); the first implementation used `TransformDirection` and a finite-difference > probe caught it 5-20% out. It diverges from `normalize(p)` by dot 0.674 on the Longinus > flank, which is why the panel needs it and the two old consumers did not. > > Carriers without the component behave exactly as before Cut 2, warning once per object. > Self sent the first attempt back: its fallback degraded shield hits to a fixed direction > until the operator edited prefabs, which is a regression on working behaviour. > > **Open (D22):** `FieldDriver`'s two branches are not interchangeable in general — > `ProjectToSurface` returns a world point, the legacy fallback stays local because that is > what the shader eats. They agree only for a carrier at the origin with identity rotation, > which is the only `FieldDriver` carrier in the tree. Settle before a second one exists. > **Cut 3 landed 2026-09-18** (`0ef88419`, `0e2fe6dc`, `5ae5c518`). `ShieldInterceptor` > poses a pooled panel per absorb event on the envelope, reusing `Prototype`. D17/D20 fixed > (a panel absorbs the hit that spawned it, and the gate stays open after), D21 fixed > (`Round` not `Ceil+1` on the substitution count): Penrose is now 2.1-4.7x Hex's cell count > at the same radius and cell size, down from ~24x, and that residual is the quantisation > floor of "nearest edge length", not a knob. Reuse strikes a live panel within > `PanelRadius * ReuseFraction` without resetting it, so temper erosion survives; at the cap > (12) the oldest panel is recycled in place. > > Found beyond the map: `OnEnable`/`OnDisable` would have rebuilt the tiling and released > every buffer on each pooled activation, which would have made pooling cost more than > rebuilding and quietly voided §2.4's whole argument. > > Two yields with a background run still going cost a round trip each; the wait rule now > names the mechanism (`~/.claude/skills/eureka/references/briefs.md`). > **Ruling R7 (operator, 2026-09-18): a panel's rim is held, not free.** "held is probably > nicer even if free is more accurate". The emitters projecting the panel hold its edge, so the > rim stops being a tension source (`ShieldSim.compute`'s `openW` ghost term) and the only > spall left comes from the shot itself through the plate. Cracks radiate from the impact and > shards leave where the hit landed, and panel size stops being a fragility multiplier. > **A broken cell's faces stay free**: that reflection is the dicing cascade and it survives. > > Operator play evidence, 2026-09-18, on the rigged scene: a panel spawns, is reused, and > spiderwebs, but only shatters when hits are spammed near the rim — which is the free edge > reflecting compression back as tension exactly as designed, and confirms the wave, the > reflection and the temper erosion all work. The interior spall source (the through-thickness > echo) is too weak or mistimed to compete. That, D9 (velocity unclamped, so hard hits saturate > in compression) and D10 (fracture evaluated once per frame while the wave integrates several > times) are Cut 5's targets. ## 1. What the repo actually has today The re-probe found the thing the first pass missed: **the ellipsoid envelope already exists twice, in two unconnected places, and neither is typed.** **Authority A — `FieldDriver`'s transform.** The ellipsoid is implicit in the field object's scale. `Assets/Scripts/Gameplay/FieldDriver.cs:125` projects a hit onto it with `normalize(transform.InverseTransformPoint(position)) * transform.localScale`; `:201` feeds `_InverseScale` to the shader; `:297-299` scales the tendril vectors by it. The only carrier in the whole tree is the `cubesphere Variant` prefab instance in `Assets/Scenes/FieldShieldTest.unity`, whose localScale is **(4, 3, 12)** at `Assets/Scenes/FieldShieldTest.unity:948-959`. `FieldDriver` is on **no ship prefab at all** — its script guid appears in exactly one asset file, that scene, at `:1042`. > Correction to the previous pass: the old map said this object was scaled `(10, 10, 20)` > (old §"Cut 3"). It is `(4, 3, 12)`. Every geometry number in the old Cut 3 was wrong. **Authority B — the `Shield` prefab instance on each ship.** `Assets/Content/Prefabs/Shield.prefab` has base localScale `(100,100,100)` at `:212` and a convex `MeshCollider` at `:218-231`. Each ship overrides it non-uniformly, and *that override is the ship's envelope today*: | Ship | localScale override | localPosition | effective radii | | --- | --- | --- | --- | | Longinus (`Assets/Content/Prefabs/Ships/Longinus.prefab:1832-1843`, pos `:1847-1858`) | (395.35, 235.47, 990.38) | (0, 0.73, 1.32) | ≈ (3.95, 2.35, 9.90) | | Djinni (`Assets/Content/Prefabs/Ships/Djinni.prefab:7378-7389`) | (857.93, 424.00, 1036.26) | (0, 1.75, -1.06) | ≈ (8.58, 4.24, 10.36), pitched 10° | `ShieldManager.ShowHit` (`Assets/Scripts/Gameplay/ShieldManager.cs:73-78`) already does the **same normalise-and-project** as `FieldDriver.cs:125`, against this transform instead: `normalize(shield.transform.InverseTransformPoint(point))`. Two implementations of one idea, neither aware of the other. The test scene's (4,3,12) is visibly an eyeball of Longinus's (3.95, 2.35, 9.90) — which is the tell that this was always one concept. So the standardisation R4 asks for is **not a new invention. It is a collapse of two existing duplicate authorities**, and the panel is its third consumer rather than its reason. **No data-side size exists.** `HullData` (`Assets/Scripts/ServerShared/ItemData.cs:493-533`) carries `Hardpoints`, `Prefab`, `HullType`, `GridOffset`, `Armor`, `Drag`, `CanTow`, `Shape` — no radius, no extents. Nothing in `Assets/Scripts` ever assigns a shield or field object's `localScale`; `ZoneRenderer.cs:295` instantiates the hull prefab and applies no scale at all. The prefab is the only place the envelope can live today. **For the record, and as a follow-up rather than a fork:** this belongs in the catalog next to `HullData`, for the same reason hardpoints do — `HardpointData.Transform` (`Assets/Scripts/ServerShared/ItemData.cs:541`) is a *GameObject name string* matched against prefab child names at runtime (`EntityInstance.cs:242,257,268`, `ShipInstance.cs:69-70`), which the operator has called unfortunate. The envelope would inherit the same unfortunateness by living on the prefab. It should move when hardpoints move, in that cut, not this one. **The real absorb publisher does not exist, but its hook does.** `PublishAbsorb` has exactly one caller and it is test-only: `Assets/Scripts/UI/FieldTester.cs:76`, from a `ClickableCollider.OnClick`. The real damage path is entirely separate — `Entity.IncomingHit` / `HullDamage` (`Assets/Scripts/ServerShared/Entity.cs:126,131`), published from `EntityInstance.cs:312,333`. But the real *shield-hit visual* path already converges on one method: `ShieldManager.ShowHit`, called from `ShieldManager.cs:65` (collision) and six weapons — `ConstantLaser.cs:83`, `ConstantLightning.cs:62`, `GuidedProjectile.cs:162`, `HitscanEffect.cs:40`, `Laser.cs:49`, `Lightning.cs:44`. **That is where a real `PublishAbsorb` belongs**, and it is a later cut, not this one. Naming it here so nobody invents a second bridge. **The pool already exists.** `Assets/Scripts/Prototype.cs` is the repo's pooling helper: `Instantiate()` at `:41`, `ReturnToPool()` at `:80`, an `OnReturnToPool` event at `:14`. `ShieldManager.ShowHit` uses it (`ShieldManager.cs:75`) for exactly the short-lived per-hit visual this design needs. **Do not write a pool.** Use this one. **The materialisation wavefront exists and is dead.** `CellStatic.spawnDelay` (`files/ShieldCommon.hlsl:20`) is baked per cell at build time from a `wavefrontOrigin` and `wavefrontSpeed` (`files/TilingBuilder.cs:225,230`), and `KUpdate` reads it as `s.growth = saturate((_Time - _PanelSpawnTime - c.spawnDelay) / _GrowDuration)` (`files/ShieldSim.compute:346`). But `files/ShieldPanel.cs:360` hardcodes `sim.SetFloat("_PanelSpawnTime", 0f)`, so the wavefront runs once at sim time zero and never again. For the interceptor it becomes the panel's arm time — **this is the mechanism R2 already wanted and it needs one line changed, not a new system.** The bake is also, by luck, already correct: an interceptor panel is *centred on* its intercept point, and the tiling is built with `wavefrontOrigin: Vector2.zero` (`files/ShieldPanel.cs:136`), which is the panel centre. So **one tiling serves every panel** and the growth wavefront still radiates from the strike. Nothing about the bake needs to become per-interception. ## 2. Probe record All measured, not reasoned. ### 2.1 Port and drawing (carried forward from the first pass, unchanged) 1. **The shader ports to built-in with three substitutions**: drop `"RenderPipeline"="UniversalPipeline"` from the SubShader tags, replace the URP `Core.hlsl` include with `UnityCG.cginc`, rename three `TransformWorldToHClip(` to `UnityWorldToClipPos(`. Result `shader supported=True, messages=0`. No other URP symbol is used. **(probe)** 2. **Indirect drawing works on built-in.** `Graphics.RenderPrimitivesIndirect` with `RenderParams`, a `MaterialPropertyBlock` carrying `GraphicsBuffer`s, and an append-buffer counter copied in by `GraphicsBuffer.CopyCount` rendered the panel. **(probe)** 3. **`RenderParams` has no `shaderPass`** in 6000.3.24f1. `files/ShieldPanel.cs:410,415,420` do not compile. **(probe)** 4. **A multi-pass material renders every pass from one call**, and pass selection is not available at this API. **(probe)** 5. **One args buffer sized to the widest pass is equivalent** — pixel-identical output to the three-call version (`litPixels=43929` both ways). **(probe)** 6. **`centroid` is reserved in the compute compiler.** `CellStatic.centroid` (`files/ShieldCommon.hlsl:18`) must become `center`. **(probe)** 7. One surviving shader warning: `Warning 172: potentially uninitialized variable (BuildLine)`, Conduit vertex program. Warning only. **(probe)** 8. **The supplied tuning never fractures anything.** Hex, `panelRadius 5`, `cellSize 0.35`, 253 cells, head-on Radial hit, 60 frames: `broken 0/253` at every energy from 3.5 to 3500. Peak tension is non-monotonic and collapses above ~60. Causes are D9/D10. **(probe)** ### 2.2 Tiling build cost — new, and it settles prebuild-vs-build-on-demand `Tilings.Generate` + `TilingBuilder.Build`, `cellSize 0.35`, single-threaded, warm JIT. **(probe)** | kind | radius | cells | edges | gen ms | build ms | **total ms** | | --- | --- | --- | --- | --- | --- | --- | | Hex | 3 | 85 | 444 | 0.07 | 0.84 | **0.91** | | Hex | 5 | 253 | 1404 | 0.20 | 2.10 | **2.30** | | Hex | 10 | 979 | 5640 | 0.72 | 20.26 | **20.98** | | PenroseP3+merge | 1.5 / 3 / 5 / 10 | 460 (all) | 1750 | 1.7–5.8 | 1.9–4.3 | **3.6–10.1** | | PenroseP3 | 1.5 / 3 / 5 / 10 | 890 (all) | 2610 | 0.3–1.7 | 2.8–3.6 | **3.1–5.3** | | TriHex | 3 | 211 | **0** | 0.30 | 1.44 | **1.74** | | TriHex | 5 | 577 | **0** | 0.84 | 8.57 | **9.41** | | TriHex | 10 | 2275 | **0** | 3.48 | 105.84 | **109.32** | | Voronoi | 3 | 251 | 1390 | 131.31 | 2.58 | **133.89** | | Voronoi | 5 | 733 | 4200 | 1014.35 | 11.65 | **1026.01** | | Voronoi | 10 | 2940 | 17246 | 14941.02 | 85.64 | **15026.67** | Three findings fall straight out of this table and are written up as D17–D19: - **Penrose ignores `panelRadius` entirely** — identical cell count at radius 1.5 and radius 10. - **TriHex produces zero dual edges at every radius.** No adjacency means no wave. It is not "probably fine", it is inert. - **Voronoi is 15 seconds at radius 10.** D7 quantified. It is not shippable at any radius where you would want it. Build cost is not the argument either way on its own — 0.91 ms for a small Hex patch is about 5% of a 60 Hz frame, which is survivable but spiky. §2.4 is what actually settles it. ### 2.3 Per-frame cost of N live panels N panels each stepped once per frame. `cpuIssueMs` is dispatch issuance only; `syncedMs` forces a GPU drain each frame via a small readback, so it is an upper bound that a real frame does not pay. Hex, `cellSize 0.35`. **(probe)** | cells/panel | N | cpu issue ms/frame | synced ms/frame | synced ms **per panel** | | --- | --- | --- | --- | --- | | 85 | 1 | 0.101 | 0.756 | 0.756 | | 85 | 4 | 0.584 | 1.752 | 0.438 | | 85 | 16 | 2.139 | 6.146 | 0.384 | | 85 | 32 | 5.262 | 10.120 | 0.316 | | 253 | 1 | 0.106 | 0.509 | 0.509 | | 253 | 4 | 0.451 | 1.449 | 0.362 | | 253 | 16 | 1.958 | 4.716 | 0.295 | | 253 | 32 | 4.438 | 11.605 | 0.363 | **The load-bearing result: per-frame cost is flat in cell count and linear in panel count.** 85 cells and 253 cells cost the same per panel. That is because a panel's frame is ~17 compute dispatches, one `SetData`, one `CopyCount` and one indirect draw *regardless of how many cells it holds* — the cell count rides inside the dispatches, where it is nearly free at these sizes. So the budget is a **panel-count budget, not a cell budget**, and the constant is roughly **0.12 ms of CPU issuance per live panel** at either size. Sixteen live panels is ~2.0 ms of CPU every frame, before the GPU does anything — about 12% of a 60 Hz frame for one ship's shield. That is the number the pool cap has to respect. ### 2.4 Recycle vs rebuild — this settles the pool Re-arming an existing panel (`ResetSim`, two dispatches) against building one from scratch (`Rebuild`: tiling + 12 `GraphicsBuffer`s + a `Material`). Hex, 20 iterations each. **(probe)** | cells | Rebuild ms | ResetSim ms | ratio | | --- | --- | --- | --- | | 85 | 1.32 | 0.011 | **123×** | | 253 | 3.33 | 0.008 | **428×** | | 979 | 20.91 | 0.008 | **2686×** | Recycling is **free and flat** — 8 microseconds, independent of cell count, because it is two GPU dispatches and nothing else. Building is 1.3–21 ms and scales with cells. **Decision, from the number and not from taste: prebuild.** One tiling per shield item, built once at load; a preallocated pool of armed `ShieldPanel` instances; interception costs a `ResetSim` and a transform write. Building a tiling at interception time would drop a 1–21 ms spike into the exact frame the player is being shot in, which is the worst possible frame to spend it, and buys nothing — every panel of one shield item is the *same tiling*. ## 3. Target A shield item's presentation is an interceptor. On each absorbed hit it spawns a hard-light panel at the intercept point on the ship's ellipsoid envelope, normal along the envelope's surface normal there, sized by the item. The panel materialises outward from the strike, takes that hit and any subsequent hit that lands inside it, and dies. Invariants: - **One owner of the envelope.** `ShieldEnvelope` is the only thing that answers "where is the surface, and which way does it face". `FieldDriver`, `ShieldManager` and the panel presenter all read it and none of them recompute it. - **One owner of the hit.** `ShieldPanel.Hit` is the only entry into a panel's simulation, and its presenter is the only caller. - **One event, one strike.** An `AbsorbEvent` produces exactly one strike — on a new panel or on a living one, never both, never neither. - **Presentation owns only presentation.** The presenter converts an event into a pose and an energy. It decides nothing about the rule. (`CapabilityPresenters.cs:8`, and the fork-L contract at `CapabilityEvents.cs:9-20`.) - **Presentations do not see each other.** `FieldDriver` and the panel presenter both subscribe and neither reads the other (R1). The binder already supports this without change: `CapabilityPresentationBinder.cs:32-36` subscribes every `IAbsorbPresenter` it finds. - **No second render pipeline, no `Unity.Mathematics`.** Unchanged from the first pass; `ProjectSettings/GraphicsSettings.asset:41` is still `m_CustomRenderPipeline: {fileID: 0}`. - **No allocation at interception.** The pool is preallocated and the tiling is prebuilt. An interception costs a `ResetSim` and a transform write (§2.4). ## Cut 1. Land the sources, compiling, on built-in Unchanged in substance from the committed pass, minus the parts R2 made pointless. Depends on nothing; touches no file the Addressables cuts moved (nothing here goes near `Assets/Content` or `Assets/Resources`). **Location.** `Assets/Shaders/Compute/ShieldField/`, matching `Assets/Shaders/Compute/Lightning/` and `Slime/`, which each hold their `.compute`, `.shader`, `.cginc` and driver `.cs` together. Colocation is load-bearing: `#include "ShieldCommon.hlsl"` resolves relative to the including file **(probe)**. No asmdef — the files land in `Assembly-CSharp` with everything else in `Assets/Shaders/Compute/*.cs`. The `ShieldField` namespace collides with nothing. **Deletes first:** - `files/ShieldPanel.cs:450-487` — `ShieldPanelTester`. The standalone click harness the operator ruled against, and it cannot run anyway: `ProjectSettings/ProjectSettings.asset:797` is `activeInputHandler: 1` (new Input System only), so `Input.GetMouseButtonDown` throws. - `files/ShieldPanel.cs:90` — `argsOutline`, `argsConduit` and their `MakeArgs` calls at `:172-173`; the `drawFill`/`drawOutline`/`drawConduits` bools at `:68-70` and their branches at `:408-422`. Replaced by one args buffer and one draw (§2.1.5). - `files/ShieldPanel.cs:9` — `[ExecuteAlways]`. With it, `Update` runs the whole simulation and every dispatch in every open scene view, and `OnEnable` rebuilds the tiling on every domain reload. Keep `Rebuild` on the context menu. **Per-file changes:** - `ShieldCommon.hlsl:18` — `float2 centroid;` → `center`. Every `.centroid` in `ShieldSim.compute` and `ShieldPanel.shader` follows. Required to compile (§2.1.6). - `ShieldCommon.hlsl:31` — add `float pad2;`, taking `CellStatic` to 16 floats. - `TilingBuilder.cs:27` — add `public float pad2;`, initialise at `:242`. Today the struct is 15 floats (60 bytes) while `Stride` at `:28` claims `4 * 16`, and `SetData` throws on the mismatch **(probe)**. Fix both sides or neither. - `TilingBuilder.cs:214,221` — rename the inner `dist` to `nbrDist`. CS0136 against the `dist` at `:225`; the file does not compile as delivered **(probe)**. - `ShieldPanel.shader:27` — drop the URP tag, add `"IgnoreProjector"="True"`. - `ShieldPanel.shader:33` — `#include "UnityCG.cginc"`. - `ShieldPanel.shader:162,163,244` — `TransformWorldToHClip(` → `UnityWorldToClipPos(`. - `ShieldPanel.cs:171-173` — one `argsFill = MakeArgs((uint)(MaxV * 2 * 6));`. - `ShieldPanel.cs:369-371` — one `CopyCount(bVisible, argsFill, sizeof(uint))`. - `ShieldPanel.cs:408-422` — one `RenderPrimitivesIndirect`, no `rp.shaderPass`. - `ShieldPanel.cs:296-298` — hoist the per-frame `new uint[] { 0u, cap }` to a cached field. - Defect fixes D3–D8 from §7, each named in the commit. **Authority map:** - Owner: `ShieldPanel` owns one panel's simulation and its GPU buffers. `TilingBuilder` owns adjacency, areas and ghost weights. `Tilings` owns geometry generation. - Inputs: authored fields; `Hit(worldPos, worldDir, energy, pattern, radius)` from outside. - Outputs: one indirect draw's worth of pixels; GPU state readable for verification. - Derived: `worldBounds`, `groups`, `echoSlot`, `simTime`. - Forbidden writers: nothing outside `ShieldPanel` touches its buffers. `ShieldPanelTester` is deleted rather than kept as a second hit source. - Deletion line: `ShieldPanelTester`, two args buffers, three draw bools, the `shaderPass` writes, and `[ExecuteAlways]` are gone before Cut 2 is written. **Verification:** batchmode compile of the Aetheria project, zero errors. `ShaderUtil.GetShaderMessages` / `GetComputeShaderMessages` report zero errors (the §2.1.7 warning may remain). Negative: `rg "UniversalPipeline|TransformWorldToHClip" Assets/Shaders/Compute/ShieldField` is empty; `rg "shaderPass" Assets` is empty. No operator look yet. ## Cut 2. `ShieldEnvelope` — collapse the two implicit ellipsoids into one This is the R4 cut, and it is a **teardown, not an addition**. Two authorities (§1 A and B) become one reader, and both existing consumers are converted to it before the panel exists. Depends on nothing; can land in parallel with Cut 1. **Adds:** `Assets/Scripts/Gameplay/ShieldEnvelope.cs`, one component. **Shape, and why this shape.** The component is a typed **reader of its own transform**, not a new store of numbers: ```csharp public class ShieldEnvelope : MonoBehaviour { // Radii and centre come from this transform. They are not authored twice. public Vector3 Center => transform.position; public Vector3 Radii => transform.lossyScale * 0.5f; // see note on the 100x base public Vector3 ProjectToSurface(Vector3 worldPoint); // local, normalise, scale, back public Vector3 SurfaceNormal(Vector3 worldSurfacePoint); // gradient, see below } ``` The operator asked for "a `ShieldEnvelope` component with centre + three radii, **or whatever the Body argues for**". The Body argues for **derived, not authored**, and the reason is ownership: the two ships already have their envelope authored, as the `Shield` prefab instance's transform (`Longinus.prefab:1832-1858`, `Djinni.prefab:7378-7389`), and the convex `MeshCollider` on that same object (`Shield.prefab:218-231`) is *already* driven by it. Authoring centre and radii as separate serialized fields next to a transform that also decides the collider would create a second truth about the same surface and a silent way for them to disagree — the split this map exists to avoid. One authority, three readers. The cost of that choice, stated plainly: the envelope is only as expressive as a transform, so it is an ellipsoid with a rotation and no more. That is exactly what R2 asked for, and if a hull ever needs a non-ellipsoid envelope this component is the place that changes. `Radii` must account for `Shield.prefab`'s `(100,100,100)` base (`Shield.prefab:212`) and for whether the mesh at `Shield.prefab:218-231` is a unit-diameter or unit-radius sphere. **Hands must read that mesh's bounds and derive the factor rather than assuming `0.5`** — get it wrong and every panel is posed at twice or half the right distance, which looks like a bug in the panel and is not. **`SurfaceNormal` is not `normalize(p)`.** For an ellipsoid with radii `(a,b,c)` centred at the origin, the outward normal at a surface point `p` is `normalize(p.x/a², p.y/b², p.z/c²)`, then back to world through the transform's rotation. `normalize(p)` is only correct for a sphere, and Longinus's envelope is 4:1 in aspect, so the error is large and visible — panels would visibly tilt wrong along the flanks. Both existing consumers get away with `normalize` today because they use it to pick a *direction on the surface*, not a *normal to it*. The panel needs the normal (R2) and this is where the distinction is paid for. **Deletes / demotions — the part the model will want to skip:** - `FieldDriver.cs:125` stops computing its own projection. It reads `envelope.ProjectToSurface(position)`. **`FieldDriver.transform.localScale` is no longer an owner of the envelope; it is display geometry for the field mesh only.** - `FieldDriver.cs:201` and `:297-299` read `envelope.Radii` rather than `transform.localScale`. If a `ShieldEnvelope` is absent, `FieldDriver` must log once and fall back to its own transform — explicitly a compatibility path, and it delegates rather than keeping its own opinion. - `ShieldManager.cs:76` stops computing `normalize(InverseTransformPoint(point))` and reads the envelope. `ShieldAnimation.Direction` keeps its existing meaning. **Scene/prefab changes:** add `ShieldEnvelope` to the `Shield` object on `Longinus.prefab` and `Djinni.prefab`, and to the `cubesphere Variant` instance in `FieldShieldTest.unity`. No transform value changes — that is the point; the numbers already there become the typed authority instead of being re-entered somewhere else. **Authority map:** - Owner: `ShieldEnvelope` owns the answer to "where is the surface and which way does it face". The transform it sits on owns the numbers. - Inputs: its own transform. Nothing else. - Outputs: `Center`, `Radii`, `ProjectToSurface`, `SurfaceNormal`. - Derived state: all of it. The component stores nothing. - Forbidden writers: nothing writes a `ShieldEnvelope`. After this cut, **no other code may compute a projection onto the shield surface** — `FieldDriver.cs:125` and `ShieldManager.cs:76` are the two that must stop, and the negative check below pins it. - Shared paths: collision hits (`ShieldManager.cs:65`), the six weapon call sites, the test scene's clicks, and the panel presenter all resolve the surface through this one method. - Deletion line: the two hand-rolled projections go before the panel presenter is written. **Verification:** - test: `ShieldEnvelope` is pure math on a transform, so this one *can* be unit-tested — but `Tests.asmdef` cannot reference `Assembly-CSharp` (see Q7), so it is a batchmode probe: for each of Longinus's and Djinni's envelopes, assert `ProjectToSurface` of a point outside lands on the ellipsoid (`Σ(pᵢ/rᵢ)² == 1` within epsilon) and that `SurfaceNormal` at that point is within 1e-3 of the finite-difference gradient of the implicit surface. **The sphere-vs-ellipsoid distinction above is exactly what this test pins**, so it must run on Longinus's 4:1 envelope and not on a sphere. - negative: `rg "InverseTransformPoint" Assets/Scripts/Gameplay` returns no shield-surface projection outside `ShieldEnvelope.cs` — specifically that `FieldDriver.cs:125` and `ShieldManager.cs:76` no longer contain one. - operator: `FieldShieldTest` behaves exactly as it does today. This cut is a no-op to look at, and if anything changes visually, something is wrong. ## Cut 3. The interceptor presenter and the panel pool Depends on Cuts 1 and 2. This is where R2 lives. **Adds:** `Assets/Scripts/Gameplay/ShieldInterceptor.cs`, one component, `MonoBehaviour, IAbsorbPresenter`. **Adds no pool** — `Assets/Scripts/Prototype.cs` is the pool (`Instantiate()` at `:41`, `ReturnToPool()` at `:80`). **Shape:** ```csharp public class ShieldInterceptor : MonoBehaviour, IAbsorbPresenter { public ShieldEnvelope Envelope; public Prototype PanelPrototype; // a prefab carrying one ShieldPanel public int MaxLivePanels = 12; // see the budget in §2.3 public float PanelRadius = 2f; // the SHIELD ITEM's size, not the hull's (R2) public float EnergyScale = 1f; public FracturePattern[] PatternByDamageType; public void Absorb(AbsorbEvent e) { /* project, reuse-or-spawn, strike */ } } ``` **The event is enough, and here is exactly what it carries.** `AbsorbEvent` (`Assets/Scripts/ServerShared/CapabilityEvents.cs:21-35`) has `float3 Position`, `float3 Direction`, `float Magnitude`, `DamageType DamageType`. The publisher (`FieldTester.cs:76`) passes `hit.point` and `ray.direction` — both **world space**, position being the raycast's contact point on the field's `MeshCollider`. So: - **Intercept point** = `Envelope.ProjectToSurface(e.Position.ToUnity())`. The projection is needed and is not optional: `hit.point` lands on whatever collider was struck, which is the field mesh in the test scene and will be the hull or the `Shield` collider in the real path — none of which is guaranteed to be on the envelope. - **Which projection**: normalise-and-scale, matching `FieldDriver.cs:125` and `ShieldManager.cs:76`, not ray-ellipsoid. Two reasons. It is what the two existing consumers already do, so the panel appears where the existing ripple appears — and the two visuals run together under R1, so a disagreement between them would read as a bug. And ray-ellipsoid needs a ray origin the event does not carry; `Direction` alone does not give one. **If the operator later wants true ray-ellipsoid, `AbsorbEvent` has to grow an origin**, which is a capability-contract change and not a presentation change. Noted, not forked. - **Panel normal** = `Envelope.SurfaceNormal(interceptPoint)` (R2). - **Panel pose** = `position = interceptPoint`, `rotation = Quaternion.LookRotation(normal)`. `ShieldPanel` builds its disc in local XY with its normal on `+Z` and draws through `mpb.SetMatrix("_PanelToWorld", transform.localToWorldMatrix)` (`files/ShieldPanel.cs:394`), so **posing the transform is the entire pose plumbing and no code changes for it.** - **Scale must be 1 and the panel must not be parented to the envelope object.** The `Shield` instance's scale is non-uniform (395, 235, 990) — parenting under it would ride that shear into `_PanelToWorld` and skew every shard. Parent to the ship root, or to the interceptor's own object if that carries unit scale. - **Size** = `PanelRadius`, authored on the interceptor, which stands in for the shield item until items own presentation data. Explicitly *not* derived from the envelope (R2). - **Energy** = `e.Magnitude * EnergyScale`; **pattern** from `PatternByDamageType` indexed by `(int)e.DamageType`, falling back to `Radial`. **Reuse — the rule that makes temper erosion visible.** A second hit landing inside a living panel must strike *that* panel, not spawn a new one. Without this the temper mechanism (`files/ShieldSim.compute:286-298`) is invisible: every hit would land on a fresh, fully-tempered panel and the "survive two, die on the third" behaviour that the whole material model exists for could never happen. Rule: keep the live panels in a list. On a strike, find the live panel whose centre is nearest the intercept point; if that distance is `< PanelRadius * ReuseFraction` **and** the panel is grown, call `Hit` on it and spawn nothing. Otherwise spawn. `ReuseFraction` starts at 1.0 (anywhere inside the disc) and is a tuning field, not a fork. The comparison is in world space against the panel's transform position; no need to go into panel space, and going into panel space would be wrong for a hit near the rim of a strongly curved envelope. **The test that pins it** (batchmode, and it is the one test in this map that must not be skipped): 1. Build an envelope, an interceptor with `MaxLivePanels >= 2`, and a pool. 2. Publish an `AbsorbEvent` at point P. Assert exactly one panel is live. 3. Step frames until that panel is grown (`growth >= 0.6` on the cells near the centre — read back `bState`). 4. Publish a second `AbsorbEvent` at P + a small offset well inside `PanelRadius`. **Assert the live-panel count is still one**, and assert the struck panel's minimum `temper` has fallen below what one hit produced — i.e. the second strike reached the same material, which is the thing that matters and is stronger than counting panels alone. 5. Publish a third at P + `PanelRadius * 3`. Assert the live count is now two. Step 4's temper assertion is what separates "did not spawn" from "actually struck". A panel count alone would pass if the second event were silently dropped. **Lifecycle — spawn, grow, take hits, die, release.** - **Spawn:** `PanelPrototype.Instantiate()`, pose it, `ResetSim()`, set `_PanelSpawnTime` to the panel's own arm time (D21), `Hit(...)`. Cost: 8 µs plus a transform write (§2.4). - **Grow:** the existing per-cell wavefront, now actually driven — `spawnDelay` is baked from the panel centre (`files/ShieldPanel.cs:136`, `TilingBuilder.cs:225`) and the panel centre is the intercept point, so it radiates outward from the strike for free. - **Die:** when every cell is either broken-and-past-`shardLife` or has decayed. `KUpdate` already declines to append dead cells (`files/ShieldSim.compute:352`), so the panel's visible count reaching zero is the signal. Reading that back every frame would stall the GPU, so **use a timer**: `growDuration + shardLife + a margin`, started at arm time. It is a presentation, and a fixed lifetime is honest here; the readback would cost more than it buys. - **Release:** `ReturnToPool()`, remove from the live list. - **Pool exhausted** (`MaxLivePanels` reached): **recycle the oldest live panel** — return it and immediately re-arm it at the new intercept point. Do not drop the strike (that breaks "one event, one strike") and do not grow the pool (that breaks the §2.3 budget). Oldest-first is right because the oldest is the one furthest through its own fade. **Authority map:** - Owner: `ShieldInterceptor` owns the live-panel set, the spawn/reuse decision, and each panel's pose. It owns nothing about the material. - Inputs: one `AbsorbEvent`; one `ShieldEnvelope`; its authored fields. - Outputs: `Prototype.Instantiate` / `ReturnToPool` calls, transform writes, `ShieldPanel.Hit` calls. - Derived state: the live list and each panel's arm time. No per-hit state beyond that — `ShieldPanel` already queues hits (`files/ShieldPanel.cs:82`). - Forbidden writers: **nothing else may spawn, pose, or return a `ShieldPanel`.** The interceptor never touches `CapabilityEvents` (the binder owns subscription, `CapabilityPresentationBinder.cs:32-36`), never reads `FieldDriver` (R1), never writes the envelope, and never computes a surface projection itself (Cut 2). - Shared paths: the test scene's clicks and the eventual real publisher (`ShieldManager.ShowHit`'s six weapon call sites, §1) reach this through the same `Absorb` method, because both go through `CapabilityEvents.Absorb`. **A panel spawned by a click and a panel spawned by a laser must be the same code path, posed by the same method** — if a later cut adds a separate "real hit" entry point, this invariant is gone. - Deletion line: none in this cut, but it is gated on Cut 2 having already deleted the two hand-rolled projections. Writing this against the un-collapsed envelope would create a third. **Verification:** - test: the five-step reuse probe above. Plus: a strike with `Envelope == null` or `PanelPrototype == null` does not throw. Plus: publishing `MaxLivePanels + 3` widely-separated events leaves exactly `MaxLivePanels` live and issues no allocation after the first `MaxLivePanels` spawns. - negative: `rg "ShieldPanel" Assets/Scripts --glob '!ShieldInterceptor.cs'` finds no second caller of `Hit`, `Instantiate` or `ReturnToPool` on a panel. `rg "localScale" ` over the interceptor is empty — it never scales a panel. - operator: nothing to look at yet; Cut 4 rigs it. ## Cut 4. The scene rig Depends on Cuts 1–3. Touches `Assets/Scenes/FieldShieldTest.unity` and one new prefab. **Geometry, re-anchored.** The field is the `cubesphere Variant` instance at scene root, localScale **(4, 3, 12)** (`FieldShieldTest.unity:948-959`), localPosition zero, camera at `(0, 0, -50)`. A real `Longinus` hull already sits inside it at `:1189`. So the rig is: - `ShieldEnvelope` on the field object (Cut 2 already adds it). - `ShieldInterceptor` on the **field object**, next to `FieldDriver`, `ClickableCollider` (`:1077-1088`) and `CapabilityPresentationBinder` (`:1089-1100`), because the binder finds presenters with `GetComponentsInChildren` on its own object (`CapabilityPresentationBinder.cs:32`). Both `FieldDriver` and `ShieldInterceptor` are then subscribed — which is R1, achieved with no code change. - A new `ShieldPanel` prefab carrying a `Prototype` and a `ShieldPanel`, with `sim` and `panelShader` assigned. `PanelRadius` on the interceptor, not on the prefab — the interceptor pushes it, because it stands in for the item. - `PanelRadius` starting value: the envelope's smallest radius is 1.5 (y = 3/2), so a panel radius around **1.5–2** reads as a patch rather than a wall. At `cellSize 0.35` that is roughly 19–50 Hex cells, well inside budget. **Note for the record, not a cut:** the flat-disc panel the first pass designed is still a perfectly good shape for a *different* shield item — a "panel array" that presents a fixed plate rather than spawning patches. R3 says a presentation is a drop-in behavioural replacement, so that item is a later `IAbsorbPresenter` beside this one, reusing the same `ShieldPanel` and the same envelope, with no pool and no reuse rule. Nothing in this map should be shaped to prevent it, and nothing here does. **How the scene is changed.** A throwaway editor script under `Assets/Scripts/Editor/`, run once by `-batchmode -executeMethod`, that opens the scene, adds the components, builds the prefab, assigns references by `AssetDatabase.LoadAssetAtPath`, and saves. **The script is deleted in the same commit; the scene diff is the artifact.** Hand-editing scene YAML next to a prefab instance with an override list is the exact shape that produced `1428ada3` ("Repair the Addressables map after a bad sed prefixed every line"). Q4 keeps the alternatives open. **Authority map:** - Owner: the scene owns the rig. The editor script is a one-shot actuator with no standing authority and no life after the commit. - Forbidden writers: nothing creates a `ShieldInterceptor` or an envelope at runtime. `FieldTester` is **not** modified — it already publishes the events, and giving it knowledge of the panel would re-split the fork-L authority. - Deletion line: the editor script, before the commit closes. **Verification:** - test: batchmode headless open of `FieldShieldTest.unity` asserting exactly one `ShieldInterceptor`, one `ShieldEnvelope`, a non-null prototype, and that the binder resolves both `FieldDriver` and the interceptor as `IAbsorbPresenter`. - **operator — this is the first look, and here is exactly what to look at:** 1. Enter play mode. Nothing is visible until you click. **If a panel is visible on load, the pool is arming itself and Cut 3's lifecycle is wrong.** 2. Click the field near the **nose** (far +Z). A lattice patch materialises at the click, growing outward from it, lying flat against the surface. The existing field ripple runs at the same time (R1) — both, not one. 3. Click near the **flank** (far +X, where the envelope is 4:1). **The panel must lie flat against the flank, not tilt toward the ship's centre.** This is the single thing that falsifies the `SurfaceNormal` gradient — a `normalize(p)` normal is visibly wrong here and correct at the nose, so checking only the nose proves nothing. 4. Click the **same spot twice**, about a second apart. The second click must **not** produce a second panel; it must strike the one already there. 5. Click **many times, far apart, quickly**. Count never exceeds `MaxLivePanels`; the oldest patch is replaced rather than the newest being dropped. 6. Let them all fade. Every panel disappears; nothing is left behind. 7. Whether anything **shatters** is Cut 5, and at this point the honest answer is no. ## Cut 5. Make a hit actually break the panel Depends on Cut 4 and on ruling Q1. A tuning and mechanism pass, not a wiring pass. §2.1.8 shows no energy fractures the panel at the supplied defaults; do not tune by eye first, because two mechanism defects bound what tuning can reach. - `ShieldSim.compute:226-232` (D9) — `u` is clamped to `±_StressClamp`, `v` is not. A hard hit drives `v` past what the clamp permits, `u` pins in compression, and the field never swings back into tension. That is the measured collapse above energy 60. Clamp `v` too, or normalise the injection so energy maps to a bounded velocity. - `ShieldPanel.cs:301,359` (D10) — `_Dt` is set to `fixedSubstepDt` before the injection loop and not restored until after `KFracture` is dispatched, so `KFracture` integrates damage and temper erosion with a substep `dt` while running **once per frame**. Damage accrues ~6× slow. Either dispatch `KFracture` inside the substep loop (physically honest, five more dispatches per frame — and §2.3 says dispatch count is what costs, so this is not free at 16 live panels) or hand it the frame `dt` (one line). Say which in the commit; this is "which clock owns fracture", not a constant. - `ShieldSim.compute:160,191,276` (D17) — **the interceptor's own worst defect.** `KInject`, `KWaveStep` and `KFracture` all skip cells with `growth < 0.6`. A panel spawned by an interception is at `growth = 0` in the frame it is struck, so **the hit that created the panel injects nothing at all.** Every panel would materialise inert and only respond to the *second* hit. Fix by one of: arm the panel with `_PanelSpawnTime` back-dated so the centre is already grown; hold the strike in `pending` until growth passes the gate (the queue at `files/ShieldPanel.cs:82` already exists for this); or drop the gate for the injecting cell. **This must be settled before the operator look in Cut 4 step 2, or that step shows a growing-but-dead patch and reads as a tuning problem when it is a gating problem.** It is listed here rather than in Cut 3 only because the fix lives in the sim. - Then the tuning itself: `ShieldPanel.cs:34,53,56` and the interceptor's `EnergyScale`. **Verification:** - test: batchmode energy sweep, readback of `bState`, asserting weak energy leaves `broken == 0` while strong energy breaks a bounded non-zero fraction — the same readback shape as §2.1.8, already written once. - negative: no energy in the authored range leaves `maxPeakTension == 0` with `minTemper == temperInit` (the saturation signature). And: a panel struck in its **spawn frame** shows non-zero `|v|` in the cells near the centre — the D17 negative. - operator: strong hits dice the patch. **Repeated hits on the same spot break through on a later hit than the first** — this is the payoff of the Cut 3 reuse rule and the temper mechanism together, and it is the whole reason the reuse rule exists. ## 6. Cost and budget **Build budget.** One tiling per shield item, prebuilt at load. Hex at the interceptor's working size costs **0.91 ms (85 cells, r3)** to **2.30 ms (253 cells, r5)**, once, ever (§2.2). The pool's `ShieldPanel` instances are preallocated: 32 instances at 253 cells cost 111 ms to construct (§2.3), which is a load-time cost and must not happen during play. **Frame budget.** ~0.12 ms CPU dispatch issuance per live panel, flat in cell count (§2.3). `MaxLivePanels = 12` is ~1.4 ms/frame; 16 is ~2.0 ms. **Cap at 12 for one ship.** The cap is the real budget knob; cell count is not, and raising `cellSize` to "optimise" would only cost visual density for nothing. **Interception cost.** `ResetSim` + transform write = **8–11 µs** (§2.4), flat. Free. **Static-buffer sharing, named and deliberately deferred.** `bCells`, `bVerts` and `bEdges` are byte-identical across every panel of one shield item, so the pool currently holds N copies of the same data — at 253 cells that is ~40 KB × N of pure duplication and N redundant uploads at load. Hoisting them into one shared owner is the obvious subtraction. It is **not** in this map because it changes `ShieldPanel`'s buffer ownership, which §2.3 says buys no frame time (the cost is dispatch count, not data), and Cut 1's authority map says `ShieldPanel` owns its buffers. Do it as its own cut when the pool is real and the duplication is measurable, not speculatively. ## 7. Defects in the supplied code Confirmed by compiling and running, not by reading. D1–D4 are fatal to compilation; D17 is fatal to the interceptor design specifically. | # | Where | Finding | | --- | --- | --- | | D1 | `files/TilingBuilder.cs:214` | CS0136: `dist` shadows the `dist` at `:225`. Does not compile. **(probe)** | | D2 | `files/ShieldPanel.cs:410,415,420` | `RenderParams.shaderPass` does not exist in 6000.3.24f1. **(probe)** | | D3 | `files/TilingBuilder.cs:28` vs `:12-27` | `Stride = 4 * 16` but the struct is 15 floats; `SetData` throws. **(probe)** | | D4 | `files/ShieldCommon.hlsl:18` | `centroid` is reserved in the compute compiler. **(probe)** | | D5 | `files/ShieldPanel.cs:411-421` | Pass selection does not exist at this API; the three per-pass args buffers cannot work as designed, independent of D2. **(probe)** | | D6 | `files/ShieldSim.compute:38` | Comment says `_BreakBudget` is a "single element"; the C# allocates two and reads index 1. **A lying comment, not a bug.** | | D7 | `files/Tilings.cs:302-333` | `VoronoiCells` is O(n² log n). **Quantified in §2.2: 134 ms at r3, 1.03 s at r5, 15.0 s at r10.** Unusable. **(probe)** | | D8 | `files/Tilings.cs:286-288` | `Vector2.zero` is both the invalid-cell sentinel and a legal centroid. `idx` at `:292,297` is dead. | | D9 | `files/ShieldSim.compute:226-232` | `u` clamped, `v` not; hard hits pin the field in compression and no tension reflects. The measured collapse in §2.1.8. **(probe)** | | D10 | `files/ShieldPanel.cs:301,356,359` | `KFracture` runs once per frame but integrates with the substep `dt`; damage accrues ~6× slow. | | D11 | `files/ShieldSim.compute:326-330` | `_V[e.nbr] +=` is a non-atomic read-modify-write across threads; the cascade is nondeterministic. Fine for an effect; fatal to any test pinning an exact shard count. | | D12 | `files/ShieldPanel.cs:296-298` | A two-element `uint[]` allocated every frame. **Worse under the pool: this is now N allocations per frame, not one.** Cache it. | | D13 | `files/ShieldPanel.cs:9` | `[ExecuteAlways]` runs the full sim in every editor scene view and rebuilds on every domain reload. **Incompatible with a pool** — `OnEnable → Rebuild` would run a 1–21 ms tiling build every time `Prototype.Instantiate` wakes an instance (§2.4). Must go; arming becomes explicit. | | D14 | `files/ShieldPanel.cs:426-440` | `ReleaseAll` does not clear `pending`, so hits queued before a rebuild are injected into the new tiling. **Now a real bug, not a principle:** a recycled pool panel would fire the previous victim's queued hit. | | D15 | `files/ShieldPanel.shader` Conduit pass | `Warning 172: potentially uninitialized variable (BuildLine)`. Warning only. **(probe)** | | D16 | `files/Tilings.cs:91-116` | TriHex interstitial triangles are placed by a separate polar construction rather than from the hex corners they share, so welding is not guaranteed. **Superseded by D19 — it is worse than suspected.** | | **D17** | `files/ShieldSim.compute:160,191,276` | **`growth < 0.6` gates injection, wave and fracture. A panel spawned at the intercept point is at `growth = 0` when struck, so the hit that created it does nothing.** Fatal to the interceptor design. See Cut 5. | | **D18** | `files/Tilings.cs` Penrose path | **Penrose ignores `panelRadius`** — 460 cells (merged) / 890 (unmerged) at radius 1.5 *and* radius 10, identically. R2 says panel size comes from the shield item, so Penrose cannot express size at all. Hex can. **(probe)** | | **D19** | `files/Tilings.cs:91-116` | **TriHex produces zero dual edges at every radius** (0 edges at r1.5, 3, 5, 10). No adjacency means no wave, no fracture, no anything. It is inert, not merely suspect. **(probe)** | | **D20** | `files/ShieldPanel.cs:360` | `_PanelSpawnTime` is hardcoded `0f`, so the materialisation wavefront fires once at sim time zero and never again. Must become the panel's arm time — **this is the one line that turns the dead wavefront into the interceptor's spawn animation.** | | D21 | `files/TilingBuilder.cs:9,119` | `Limits.MaxVerts = 8`, and polygons with more vertices are **silently dropped** at `:119`. A tiling that quietly loses cells looks like a tiling bug. Log it. | Buffer lifetime, `SetCounterValue` and `CopyCount` were checked specifically and are **correct**: `bVisible` is `Append | Structured` (`files/ShieldPanel.cs:164`), reset at `:366` before the appending dispatch, copied at `:369` into offset `sizeof(uint)` (= `instanceCount` in `IndirectDrawArgs`), and every buffer is released by `ReleaseAll` on both `OnDisable` and the head of `Rebuild`. A probe readback of `argsFill instanceCount=253` against `cells=253` confirms the counter path end to end. **(probe)** ## 8. Operator questions Most blocking first. **Q1 — Does the first landing have to fracture?** (Carried over; still first, now with a second reason.) §2.1.8 says no energy breaks a cell at the supplied defaults, and D9/D10 bound what tuning alone can reach. D17 adds that the spawning hit currently injects nothing at all. **A:** land Cuts 1–4, ship an interceptor that spawns, poses and materialises panels correctly with a visible stress wave and no fracture, and take Cut 5 as its own pass. **B:** hold everything until Cut 5 lands too. **Recommended: A**, with one amendment to the previous pass's reasoning — **D17 must be fixed in A, not deferred to Cut 5**, because without it the Cut 4 operator look shows an inert patch and proves nothing about the wiring it exists to prove. Fracture can wait; injection cannot. Wiring is the thing ruled on and it is verifiable on its own. **Q2 — Is the envelope derived from the transform, or authored as explicit fields?** Cut 2 recommends **derived**, because the `Shield` prefab instance transform already owns the numbers and also drives the collider, and a second authored copy would be a second truth. The cost is that the envelope can only ever be a rotated ellipsoid. **A: derived (recommended). B: explicit centre + radii fields, transform ignored.** B is the operator's literal phrasing and would be right if the envelope is meant to diverge from the collider — if it is, say so, because that is a different machine. **Q3 — Which tiling?** D18 and D19 narrow this hard. **Penrose cannot express panel size** (identical cell count at every radius) and R2 requires size to come from the item. **TriHex is inert** (zero adjacency). **Voronoi is 15 s at r10 and 1 s at r5.** That leaves **Hex** as the only kind that is both correct and sized. **Recommended: Hex, and treat D18 as a real defect to fix later if Penrose's look is wanted** — Penrose is the better-looking tiling and losing it is a genuine cost, but it cannot ship as the interceptor's tiling until it respects `panelRadius`. **Q4 — Which fracture pattern per damage type, and where authored?** Six of each, no natural ordering. Proposed: Kinetic→Radial, Optical→Lance, Electric→Star, Thermal→Crystal, Corrosive→Fizzle, Ionizing→Spiral. **A:** a serialized `FracturePattern[]` on the interceptor indexed by `(int)DamageType`. **B:** a switch in code. **C:** item data. **Recommended: A** — presentation choice belongs with the presentation and retunes without a compile. C is wrong today: the absorb capability owns no data yet (`CapabilityEvents.cs:17-20` parks it explicitly). **Q5 — How is the scene edited?** **A:** one-shot editor script run in batchmode, deleted in the same commit. **B:** hand-edited YAML. **C:** the operator clicks it together. **Ruled C (operator, 2026-09-18): "screw it, I'll do any editor shenanigans we need for now."** Cut 4 therefore ships no editor script and no YAML edit: it ships an exact rig recipe for the operator (object names, parents, components, transform values, which asset goes in which field) plus a batchmode check that reads the saved scene back and refuses a rig that is wrong. Agent-driven editor commands through Brokkr are parked (`F:\Projects\Brokkr\docsgent-access-cut.md`). Superseded reasoning: **Recommended: A.** B is the failure mode that produced `1428ada3`. If the operator would rather keep the script as a menu item, say so — that makes it a standing second authority over the rig and should be deliberate, not leftover. **Q6 — Per-pass draw toggles?** `drawFill`/`drawOutline`/`drawConduits` die with the single-call draw (§2.1.5). **A: gone. B: back as shader uniforms zeroing each pass's alpha.** **Recommended: A**, until someone wants them. No consumer today. **Q7 — Buy NUnit coverage with an asmdef?** `Tests.asmdef` references only `UnityEngine.TestRunner`, `UnityEditor.TestRunner` and `Aetheria.Shared.Unity`, with `autoReferenced: false`, so it cannot see `Assembly-CSharp`. **A:** no asmdef; pin the rules with batchmode probes. **B:** add `Aetheria.ShieldField.asmdef`. **Recommended: A**, but weaker than last pass: `ShieldEnvelope` (Cut 2) is pure testable math and is exactly the kind of thing an asmdef would buy real coverage for. If the operator wants B, **scope it to `ShieldEnvelope` alone**, not to the six sources — the envelope is the piece whose arithmetic has a wrong answer that looks plausible (sphere vs ellipsoid normal), and it is three lines of reference edge rather than a compile boundary across 5.4k lines. ## 9. Superseded: the flat-panel design Kept because it explains why several defects are recorded the way they are, and because the shape itself is still wanted for a different item (Cut 4's note). The committed pass at `1fdf8151` designed **one permanent flat disc** parented near the hull: a `ShieldPanel` living in `FieldShieldTest` from scene load, materialising once at sim time zero, with a `ShieldPanelPresenter` converting each `AbsorbEvent` into one `Hit` and doing nothing else. It had no pool, no lifecycle, no envelope, and no pose problem — the panel's transform was authored in the scene and never moved. R2 retired it. What changed and why it matters: - **No pool → a pool.** Panels are per-interception, so `Prototype` enters the map and `[ExecuteAlways]` / `OnEnable → Rebuild` (D13) goes from "wasteful" to "incompatible", and D12/D14 go from "principle" to "real bug under recycling". - **Fixed pose → derived pose.** The panel's normal now comes from the envelope, which is what forced the discovery that two implicit ellipsoids already exist (§1) and that `normalize(p)` is the wrong normal for a 4:1 ellipsoid (Cut 2). - **Materialise once → materialise per interception.** `_PanelSpawnTime` goes from an ignored constant to the mechanism (D20), and the wavefront bake turns out to already be correct. - **No reuse rule → a reuse rule.** Without it the temper model is unobservable (Cut 3). - **Q2 "replace or alongside" is answered alongside (R1)**, so `FieldDriver.Absorb` (`FieldDriver.cs:158`) and its hit-buffer machinery (`:76,105-132,204-217`) are **not** deleted. The old map's recommendation to delete them later is withdrawn. Its geometry numbers were also simply wrong: it placed the panel against a field scaled `(10, 10, 20)`, and the scene says `(4, 3, 12)` (`FieldShieldTest.unity:948-959`). ## 10. Subtraction ledger | Cut | Removed | Added | Deps/targets | | --- | --- | --- | --- | | 1 | `ShieldPanelTester` (~38 lines), 2 args buffers, 3 draw bools, 2 draw calls, `[ExecuteAlways]` | 6 files (~2.1k lines) | none; no package, no pipeline, no asmdef | | 2 | 2 hand-rolled ellipsoid projections (`FieldDriver.cs:125`, `ShieldManager.cs:76`); `FieldDriver.transform.localScale` demoted from envelope owner to mesh geometry | 1 file (~40 lines), 3 component instances | none | | 3 | — (**no pool written**: `Assets/Scripts/Prototype.cs` is reused) | 1 file (~120 lines) | none | | 4 | the one-shot editor script, same commit | 1 prefab, 2 component instances | none | | 5 | — | ~10 lines changed | none | Net: additive, and it should be. The capability bought is a simulation that exists nowhere in the tree. The honest subtractions are Cut 2's — two duplicate projections collapsed into one owner, which is a real reduction in authority count and the only part of this map that makes the *existing* machine simpler — and Cut 3's refusal to write a pool that already exists. Cut 1's line count is the liability; it is bought with an explicitly requested capability and nothing currently in the repo can be deleted in exchange for it. The `FieldDriver` absorb visual is **not** available as a subtraction: R1 rules it stays.