From 1ff21f176fff02475d3667af3e8ea8b6142ee5b7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 18 Sep 2026 05:39:25 +0200 Subject: [PATCH] Remap the shield as an interceptor spawning panels on the hull envelope Co-Authored-By: Claude Opus 5 --- docs/shield-panel-cut.md | 1151 ++++++++++++++++++++++++-------------- 1 file changed, 740 insertions(+), 411 deletions(-) diff --git a/docs/shield-panel-cut.md b/docs/shield-panel-cut.md index 5f0e3af0d..779fcd5b8 100644 --- a/docs/shield-panel-cut.md +++ b/docs/shield-panel-cut.md @@ -1,494 +1,823 @@ -# Shield Panel: GPU Fracture Simulation Cut Map +# Shield Panel: Hard-Light Interceptor Cut Map -Date: 2026-09-18 +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 `491811bd`. The six incoming sources are not in the repo -yet; they are anchored as `files/:` against the operator's drop at -`/files/`. +`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/`. -Mechanism claims marked **(probe)** were run, not reasoned. The probe was a throwaway -Unity 6000.3.24f1 project in scratch (`/probe`), holding only the six files, a -built-in port of the shader, and one editor script driven by -`-batchmode -executeMethod`. Nothing was built in the Aetheria tree and no Unity session -was opened against it; Hands was concurrently editing `Assets/Content`, -`Packages/manifest.json` and the Addressables surfaces. Logs: `/probe{,2..6}.log`. -The probe project is scratch and will be removed with the session; §1 records what it -proved so no one has to re-run it to trust the map. +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. Target +## 0. Operator rulings, 2026-09-18 -Operator, 2026-09-18: **wire it up to real hits.** The absorb capability in the -`FieldShieldTest` scene drives the fracture panel; no standalone click-to-hit harness is -built first. +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. -Ends: +**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. -- A `ShieldPanel` exists in `FieldShieldTest`, materialises on load, and fractures when - the absorb capability publishes an `AbsorbEvent`. -- The event is the only input to the simulation. Presentation converts; it decides - nothing about the rule, matching `Assets/Scripts/Gameplay/CapabilityPresenters.cs:8` - and the fork-L contract in `Assets/Scripts/ServerShared/CapabilityEvents.cs:9-20`. -- The sources compile and render on the built-in render pipeline. Aetheria has no URP: - `ProjectSettings/GraphicsSettings.asset:41` is `m_CustomRenderPipeline: {fileID: 0}` - and `Packages/manifest.json` carries no render-pipeline package. +**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." + +Q1 (does the first landing have to fracture) is **still open** and still first; see §8. + +## 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 hit.** `ShieldPanel.Hit` is the only entry point into the - simulation. The presenter is the only caller. Nothing else reaches the panel. -- **One event, one hit.** An `AbsorbEvent` produces exactly one `Hit`, carrying the - event's own world position and direction unmodified. -- **Presentation owns only presentation.** Damage-type-to-pattern and magnitude-to-energy - are presentation mappings authored on the presenter. The absorb capability's data (the - damage-type mask) stays out of scope, as ruled 2026-09-17. -- **No second render pipeline.** Nothing in this cut adds a package, a pipeline asset, or - a URP include path. -- **No Unity.Mathematics.** The incoming sources use `UnityEngine.Vector2/Vector3` only - (verified: no `Unity.Mathematics` or `using static Unity` in any of the six). The - presenter converts `CultMath.float3` with `CultMath.UnityBridge`, the way - `Assets/Scripts/Gameplay/FieldDriver.cs:125` already does. - -## 1. Probe record - -All results from Unity 6000.3.24f1, Direct3D12, `srp=builtin`, batchmode with graphics. - -1. **The shader ports to built-in with three substitutions.** Removing - `"RenderPipeline"="UniversalPipeline"` from the SubShader tags, replacing the URP - `Core.hlsl` include with `UnityCG.cginc`, and renaming the three - `TransformWorldToHClip(` calls to `UnityWorldToClipPos(` is sufficient: - `shader supported=True, messages=0` (probe3 had one warning, see §1.7). No other URP - symbol is used — `_ScreenParams`, `noperspective`, `clip`, `saturate` and - `StructuredBuffer` at `#pragma target 4.5` are all built-in surface. **(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: - `argsFill vertsPerInstance=24 instanceCount=253`, 38036 of 65536 pixels lit. The cut - does not have to change the drawing strategy. **(probe)** -3. **`RenderParams` has no `shaderPass`.** Full property list in 6000.3.24f1: - `layer renderingLayerMask rendererPriority instanceID entityId worldBounds camera - motionVectorMode reflectionProbeUsage material matProps shadowCastingMode - receiveShadows lightProbeUsage lightProbeProxyVolume overrideSceneCullingMask - sceneCullingMask forceMeshLod meshLodSelectionBias`. `files/ShieldPanel.cs:410,415,420` - do not compile. **(probe)** -4. **A multi-pass material renders every pass from one call.** A two-pass probe shader - (red triangle in pass 0, green in pass 1) drawn once with `Graphics.RenderPrimitives` - produced `redPixels=40992 greenPixels=40983`. Pass selection is not available at this - API, so the supplied "one args buffer per pass index" design cannot work as written. - **(probe)** -5. **One args buffer sized to the widest pass is equivalent.** Setting every args buffer - to `MaxVerts * 2 * 6 = 96` vertices per instance and issuing a single - `RenderPrimitivesIndirect` produced pixel-identical output to the three-call version: - `singleCall=False litPixels=43929` vs `singleCall=True litPixels=43929`. Each pass - already culls its own surplus vertices (`files/ShieldPanel.shader:238` fill, - `:287` outline, `:347` conduit), and additive `Blend One One` with `ZWrite Off` makes - pass order irrelevant. **(probe)** -6. **`centroid` is a reserved word in the compute path.** `ShieldSim.compute` failed with - `Error 18: syntax error: unexpected token 'centroid' at kernel KInit` — the - `CellStatic.centroid` member at `files/ShieldCommon.hlsl:18`. Renaming it to `center` - in the HLSL, compute and shader gives `compute supported=True, messages=0`. The - fragment/vertex compiler tolerates it; the compute compiler does not. **(probe)** -7. **One live shader warning survives the port:** `Warning 172: use of potentially - uninitialized variable (BuildLine)`, Conduit vertex program. Warning only; the pass - renders. Worth a look while touching `BuildLine`, not worth a cut of its own. -8. **The supplied tuning never fractures anything.** Hex tiling, `panelRadius 5`, - `cellSize 0.35`, 253 cells, a head-on Radial hit at the centre, 60 frames after: - - | energy | broken | maxDamage | maxPeakTension | minTemper | - | --- | --- | --- | --- | --- | - | 3.5 (the supplied "strong" hit) | 0/253 | 0 | 0.058 | 0.547 | - | 35 | 0/253 | 0.0012 | 0.579 | 0.505 | - | 60 | 0/253 | 0.0097 | 0.743 | 0.487 | - | 90 | 0/253 | 0 | 0.076 | 0.548 | - | 120 | 0/253 | 0 | 0 | 0.55 | - | 160 | 0/253 | 0 | 0.084 | 0.549 | - | 220 | 0/253 | 0 | 0 | 0.55 | - | 350 | 0/253 | 0 | 0 | 0.55 | - | 3500 | 0/253 | 0 | 0 | 0.55 | - - A break needs `u > temper + tensileStrength*toughness ≈ 1.45` **and** `damage >= 0.75`. - Peak tension is non-monotonic in energy and collapses above ~60: the field saturates - into the compression clamp and never swings back into tension. Causes named in §5 - (D9, D10). **(probe)** This is the reason Cut 4 exists and why it is the operator's - first fork: wiring real hits to a simulation that cannot break is a pretty stress wave - and nothing else. +- **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 -- **Repo/branch:** Aetheria `codex/item-provenance` from `491811bd`. Depends on nothing. - Touches no file Hands is holding (`Packages/manifest.json`, `Assets/Content`, - `ServerShared` asset-path fields, `GameData/Aetheria.cc`, `ActionBarSlot.cs`, - `UnityHelpers.cs`). -- **First:** confirm Hands' Addressables cuts have landed or are on files disjoint from - `Assets/Shaders/`. Nothing here goes near `Assets/Resources`, which is mid-move to - `Assets/Content`. +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.** All six files go to `Assets/Shaders/Compute/ShieldField/`, matching the -repo's own convention for compute-driven effects: `Assets/Shaders/Compute/Lightning/` -holds `Lightning.compute`, `Lightning.shader`, `LightningVertex.cginc` and -`LightningCompute.cs` together; `Slime/` does the same. Colocation is also load-bearing — -`#include "ShieldCommon.hlsl"` resolves relative to the including file (probe). No import -settings are needed: `.compute` and `.hlsl` import with no configurable state. - -**Assembly.** No asmdef. The files land in `Assembly-CSharp` with everything else in -`Assets/Shaders/Compute/*.cs` and `Assets/Scripts/Gameplay/`. The `ShieldField` namespace -collides with nothing (`rg "class ShieldPanel\b|class Tilings\b|class Limits\b|enum -TilingKind|class PolygonSoup|class VertexWelder|enum FracturePattern"` over -`Assets/Scripts` and `Assets/Shaders` returns nothing). See Q7 for the testability cost. +**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`. It is 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` and `Input.mousePosition` throw at runtime. Delete - it rather than port it; the absorb path replaces it. -- `files/ShieldPanel.cs:90` — `argsOutline`, `argsConduit` and their `MakeArgs` calls - at `:172-173`, plus the `drawFill`/`drawOutline`/`drawConduits` bools at `:68-70` and - their branches at `:408-422`. Replaced by one args buffer and one draw (probe §1.5). - Per-pass toggles come back as a fork (Q6) if the operator wants them, and then as - shader uniforms, not as draw calls. +- `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 (against the supplied files):** +**Per-file changes:** -- `ShieldCommon.hlsl:18` — `float2 centroid;` → `float2 center;`. Every `.centroid` in - `ShieldSim.compute` and `ShieldPanel.shader` follows. Required to compile (§1.6). -- `ShieldCommon.hlsl:31` — add `float pad2;` after `pad1`, taking `CellStatic` to 16 - floats. -- `TilingBuilder.cs:27` — add `public float pad2;` and initialise it at `:242`. Today the - C# struct is 15 floats (60 bytes) while `Stride` at `:28` claims `4 * 16` (64), and - `SetData` throws: *"One of C# data stride (60 bytes) and Buffer stride (64 bytes) should - be multiple of other"* (probe). Padding to 16 keeps the 16-byte-aligned stride the - comment intends; the alternative, `Stride = 4 * 15`, also works and is smaller. Either - fixes it — do not fix only one side. -- `TilingBuilder.cs:214` — rename the inner `dist` to `nbrDist` (and `:221`). CS0136: it - shadows the `dist` declared at `:225` in the enclosing scope. The file does not compile - as delivered (probe). -- `ShieldPanel.shader:27` — drop `"RenderPipeline"="UniversalPipeline"`, add - `"IgnoreProjector"="True"`. -- `ShieldPanel.shader:33` — `#include "UnityCG.cginc"` in place of the URP `Core.hlsl`. +- `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 `GraphicsBuffer.CopyCount(bVisible, argsFill, sizeof(uint));`. -- `ShieldPanel.cs:408-422` — one `Graphics.RenderPrimitivesIndirect(rp, - MeshTopology.Triangles, argsFill, 1, 0);`, no `rp.shaderPass`. -- `ShieldPanel.cs:296-298` — hoist the `new uint[] { 0u, cap }` to a cached field. It - allocates every frame today. -- `ShieldPanel.cs:9` — drop `[ExecuteAlways]` (Q5). With it, `Update` runs the whole - simulation and three GPU dispatch groups every editor frame in every open scene view, - and `OnEnable` rebuilds the tiling on every domain reload. -- Defect fixes D3-D8 from §5, each named in the commit. +- `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 the simulation and its GPU buffers. `TilingBuilder` owns - adjacency, areas and the free-surface ghost weights. `Tilings` owns geometry generation. -- Inputs: authored fields on the component; `Hit(worldPos, worldDir, energy, pattern, - radius)` from outside. -- Outputs: three indirect draws' worth of pixels, and GPU state readable for verification. -- Derived state: `worldBounds`, `groups`, `echoSlot`, `simTime` are derived from the data - and the frame clock; none is authored. -- Forbidden writers: nothing outside `ShieldPanel` touches `bState`, `bUA/bUB`, `bV`, - `bEcho`, `bFlow`, `bVisible`, `bBudget` or the args buffer. `ShieldPanelTester` is +- 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. -- Shared paths: edit-mode and play-mode take the same `Update` body (once `ExecuteAlways` - is settled); `Rebuild` is the only path that allocates, and always releases first. -- Deletion line: `ShieldPanelTester`, the two surplus args buffers, the three draw bools, - and the `rp.shaderPass` writes are gone before the presenter is written. +- Deletion line: `ShieldPanelTester`, two args buffers, three draw bools, the `shaderPass` + writes, and `[ExecuteAlways]` are gone before Cut 2 is written. -**Verification:** +**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. -- builds: Unity batchmode compile of the Aetheria project (`-quit -batchmode - -projectPath . -logFile`), zero errors. `ShaderUtil.GetShaderMessages` on - `ShieldPanel.shader` and `GetComputeShaderMessages` on `ShieldSim.compute` report zero - errors (the §1.7 warning may remain). -- test: none. The rule worth pinning is in Cut 2; there is nothing here a unit test would - hold that the compiler does not. -- negative: `rg "UniversalPipeline|render-pipelines\.universal|TransformWorldToHClip" - Assets/Shaders/Compute/ShieldField` is empty. `rg "shaderPass" Assets` is empty - (verified: no legitimate use of that identifier in the tree today). -- operator: none yet. Cut 3 is the first thing to look at. +## Cut 2. `ShieldEnvelope` — collapse the two implicit ellipsoids into one -## Cut 2. The absorb presenter +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. -- **Repo/branch:** same. Depends on Cut 1. -- **Adds:** `Assets/Scripts/Gameplay/ShieldPanelPresenter.cs`, one component, - `MonoBehaviour, IAbsorbPresenter`. -- **Deletes:** nothing. `FieldDriver.Absorb` (`Assets/Scripts/Gameplay/FieldDriver.cs:158`) - stays or goes by operator ruling Q2; the binder already supports both presenters - subscribing to the same stream - (`Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs:32-36` iterates every - `IAbsorbPresenter` in the children). +**Adds:** `Assets/Scripts/Gameplay/ShieldEnvelope.cs`, one component. -**Shape:** +**Shape, and why this shape.** The component is a typed **reader of its own transform**, not a +new store of numbers: -``` -public class ShieldPanelPresenter : MonoBehaviour, IAbsorbPresenter +```csharp +public class ShieldEnvelope : MonoBehaviour { - public ShieldPanel Panel; // sibling object, not a child - public float EnergyScale = 1f; // AbsorbEvent.Magnitude -> panel energy - public float HitRadius = -1f; // -1 = the panel's own default - public FracturePattern[] PatternByDamageType; // indexed by (int)DamageType, length 6 + // 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 void Absorb(AbsorbEvent e) - { - if (Panel == null) return; - Panel.Hit(e.Position.ToUnity(), e.Direction.ToUnity(), - e.Magnitude * EnergyScale, PatternFor(e.DamageType), HitRadius); - } + public Vector3 ProjectToSurface(Vector3 worldPoint); // local, normalise, scale, back + public Vector3 SurfaceNormal(Vector3 worldSurfacePoint); // gradient, see below } ``` -`AbsorbEvent.Position` is the world hit point and `Direction` is the incoming ray -direction (`Assets/Scripts/UI/FieldTester.cs:76` publishes `hit.point` and -`ray.direction`), which is exactly what `Hit` wants — `files/ShieldPanel.cs:257-282` -transforms both into panel space itself. No projection, no normalisation, no -reinterpretation on the presentation side: that is the "one event, one hit" invariant. +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. -`PatternFor` reads the serialized table and falls back to `FracturePattern.Radial` when -the array is short or unset, so an unauthored component still works. The mapping itself is -Q3. +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: `ShieldPanelPresenter` owns the event-to-hit conversion, and nothing else. -- Inputs: one `AbsorbEvent`; three authored fields. -- Outputs: one `ShieldPanel.Hit` call. -- Derived state: none. The presenter holds no per-hit state, no queue, no timers — the - panel already queues hits (`files/ShieldPanel.cs:82`). -- Forbidden writers: the presenter never touches `CapabilityEvents`; the binder owns - subscription. It never reads `FieldDriver`. Nothing writes `ShieldPanel` fields at - runtime except its own inspector values. -- Shared paths: the operator's click and any later real absorb capability reach the panel - through the same `Absorb` method, because both go through `CapabilityEvents.Absorb`. -- Deletion line: none; this cut is purely additive, and buys the only wiring the operator - asked for. +- 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:** -- builds: project compiles. -- test: nothing in `Assets/Scripts/Tests` can reach `Assembly-CSharp` (`Tests.asmdef` - references only `UnityEngine.TestRunner`, `UnityEditor.TestRunner` and - `Aetheria.Shared.Unity`, and `autoReferenced: false`). So the rule is pinned by a - batchmode probe instead, and the probe checks it at the layer where it would break: - build a `CapabilityEvents`, a `CapabilityPresentationBinder`, the presenter and a real - panel; publish one `AbsorbEvent` at a known off-centre position; step the sim; read - `bState` back and assert the injected region is centred where the event said and that a - second identical publish doubles it. A presenter with `Panel == null` publishes and does - not throw. See Q7 for the alternative that buys NUnit coverage. -- negative: `rg "ShieldPanel" Assets/Scripts --glob '!ShieldPanelPresenter.cs'` finds no - second caller of `Hit`. +- 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 scene rig +## Cut 3. The interceptor presenter and the panel pool -- **Repo/branch:** same. Depends on Cuts 1-2. -- **Touches:** `Assets/Scenes/FieldShieldTest.unity` only. +Depends on Cuts 1 and 2. This is where R2 lives. -**Geometry.** The existing field is `Assets/Prefabs/cubesphere Variant.prefab`, a unit -cubesphere scaled `(10, 10, 20)` at the origin, with the camera at `(0, 0, -50)` looking -`+Z`. `ShieldPanel` builds a flat disc in local XY with its normal on `+Z`. So the panel -becomes a **top-level scene object** at `(0, 0, -10)` with identity rotation and unit -scale, `panelRadius = 10` — the near face of the field, same silhouette, undistorted. It -must not be parented to the field: the field's non-uniform `(10,10,20)` scale would ride -into `_PanelToWorld` and shear every shard. +**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`). -Clicks already land on the field's `MeshCollider` and the absorb event carries that world -point, whose X/Y is what the panel consumes. The panel therefore needs no collider of its -own and no change to `ClickableCollider` or the click catcher. +**Shape:** -**Wiring.** The presenter component goes on the **field** GameObject, next to -`FieldDriver`, `ClickableCollider` and `CapabilityPresentationBinder`, because the binder -finds presenters with `GetComponentsInChildren` on its own object -(`CapabilityPresentationBinder.cs:32`). Its `Panel` field points at the separate panel -object. One cross-reference, no scale contamination. +```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; -**How the scene is changed.** Hands cannot click, and hand-editing the YAML means -inventing fileIDs next to a prefab instance with an override list — the exact shape that -produced `1428ada3` ("Repair the Addressables map after a bad sed prefixed every line"). -So: a throwaway editor script under `Assets/Scripts/Editor/`, run once by -`-batchmode -executeMethod`, that opens the scene, creates the panel object, adds and -configures `ShieldPanel` (assigning the shader and compute assets by -`AssetDatabase.LoadAssetAtPath`), adds the presenter to the field object, assigns the -reference, and `EditorSceneManager.SaveScene`s. **The script is deleted in the same -commit; the scene diff is the artifact.** Keeping it would mean a second authority over -the rig that nobody would remember to re-run. Q4 offers the alternatives. + 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: the scene owns the rig. The editor script is a one-shot actuator with no - standing authority and no life after the commit. -- Inputs: the two prefab/asset GUIDs and the transform numbers above. -- Outputs: one new GameObject, one new component on the field object, one reference. -- Derived state: none. -- Forbidden writers: nothing may create a `ShieldPanel` at runtime; the scene is the only - source. `FieldTester` is not modified — it already publishes the absorb events, and - giving it knowledge of the panel would re-split the authority fork L just merged. -- Shared paths: the scene load path and the editor script write the same objects, because - the script is what writes the scene. +- 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:** -- builds: project compiles; the scene opens with no missing-script warnings. -- test: a batchmode headless open of `FieldShieldTest.unity` asserting that exactly one - `ShieldPanel` exists, that its `sim` and `panelShader` are non-null, and that the field - object's presenter resolves to it. -- negative: `rg "ShieldPanelTester" Assets` is empty. -- operator: enter play mode in `FieldShieldTest`; the panel materialises as a lattice - filling the field silhouette; clicking the field produces a visible stress wave - travelling out from the click and reflecting off the rim; the properties panel is - unchanged. Whether anything shatters is Cut 4. +- 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 4. Make a hit actually break the panel +## Cut 5. Make a hit actually break the panel -- **Repo/branch:** same. Depends on Cut 3 and on ruling Q1. -- **Blocked:** this cut is a tuning and mechanism pass, not a wiring pass. §1.8 shows no - energy fractures the panel at the supplied defaults. Do not tune by eye first; the two - mechanism defects below bound what tuning can reach. +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. -**Per-file changes:** - -- `ShieldSim.compute:226-232` — `u` is clamped to `±_StressClamp` while `v` is not. A hard - hit drives `v` far past what the clamp permits, `u` pins at `-_StressClamp`, and the - field never swings back into tension: that is the collapse above energy 60 in §1.8, and - why 3500 behaves like 120. Clamp `v` as well, or normalise the injection so energy maps - to a bounded velocity. -- `ShieldPanel.cs:301` and `:359` — `_Dt` is set to `fixedSubstepDt` before the injection - loop and not restored until after `KFracture` has been dispatched, so `KFracture` - integrates damage and temper erosion with a substep `dt` while running **once per - frame**. Damage accumulates roughly six times slower than the wave it is reading - (`substeps = 6`). Either dispatch `KFracture` inside the substep loop, or hand it the - frame `dt`. The first is physically honest and costs five more dispatches per frame; the - second is one line. Pick one and say which in the commit — this is the "which clock owns - fracture" question, not a constant. -- `ShieldPanel.cs:34,53,56` and the presenter's `EnergyScale` — the tuning itself, once - the two above are settled. +- `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: a batchmode probe that sweeps energy across the authored range, reads `bState` - back, and asserts that the weak energy leaves `broken == 0` while the strong energy - breaks a bounded, non-zero fraction of cells — the same readback shape as §1.8, which is - cheap and already written once. +- 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. -- operator: strong clicks dice the panel; repeated weak clicks eventually break through - (the temper mechanism at `ShieldSim.compute:286-298` is the whole point of the design and - is untested until a break is reachable). + `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. -## 5. Defects in the supplied code +## 6. Cost and budget -Confirmed by compiling and running them, not by reading. D1-D4 are fatal; the cut does not -exist without them. +**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`. The file does not compile. **(probe)** | +| 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 on the stride mismatch. **(probe)** | -| D4 | `files/ShieldCommon.hlsl:18` | `centroid` is reserved in the compute compiler; `ShieldSim.compute` does not compile. **(probe)** | -| D5 | `files/ShieldPanel.cs:411-421` + probe §1.4 | Pass selection does not exist at this API, so the three per-pass args buffers cannot work as designed, independent of D2. | -| D6 | `files/ShieldSim.compute:38` | Comment says `_BreakBudget` is a "single element"; the C# allocates two (`ShieldPanel.cs:162`) and `:304` reads index 1. **Not a bug — a lying comment.** The earlier reading of this as an overrun is refuted. | -| D7 | `files/Tilings.cs:302-333` | `VoronoiCells` is O(n² log n): every site scans every other site and sorts the whole list, then `Voronoi` runs it `lloydPasses + 1` times (4 for `TilingKind.Voronoi`). Confirmed by reading; not a blocker for the Hex/Penrose default, a real wait at a few thousand sites. Use a partial selection (`nth_element`-style) or reuse the grid `ChaosGame` already builds at `:212`. | -| D8 | `files/Tilings.cs:286-288` | `Vector2.zero` is both the "invalid cell" sentinel and a legal centroid, so a cell centred on the origin is silently refused relaxation. Use a nullable or a parallel validity flag. `idx` at `:292,297` is dead. | -| D9 | `files/ShieldSim.compute:226-232` | `u` is clamped, `v` is not; hard hits pin the field in compression and no tension ever reflects. The measured collapse in §1.8. **(probe)** | -| D10 | `files/ShieldPanel.cs:301,356,359` | `KFracture` runs once per frame but integrates with the substep `dt`, so damage and temper erosion accrue ~6× slow. | -| D11 | `files/ShieldSim.compute:326-330` | `_V[e.nbr] +=` from many threads is a non-atomic read-modify-write; the cascade is nondeterministic run to run. Acceptable for an effect, worth knowing before anyone tries to write a test that pins an exact shard count. | -| D12 | `files/ShieldPanel.cs:296-298` | A two-element `uint[]` is allocated every frame for the break budget. Cache it. | -| D13 | `files/ShieldPanel.cs:9` | `[ExecuteAlways]` runs the full simulation, all dispatches and all draws every editor frame, and rebuilds the tiling on every domain reload. See Q5. | -| D14 | `files/ShieldPanel.cs:426-440` | `ReleaseAll` does not clear `pending`, so hits queued before a rebuild are injected into the new tiling. Harmless today; wrong in principle. | -| D15 | `files/ShieldPanel.shader` Conduit pass | `Warning 172: use of potentially uninitialized variable (BuildLine)`. Warning only. **(probe)** | -| D16 | `files/Tilings.cs:91-116` | The TriHex interstitial triangles are placed by a separate polar construction rather than from the hex corners they should share, so welding is not guaranteed and the dual graph can come out with spurious free surfaces. Untested; `Hex` and `PenroseP3` are the safe defaults until someone looks. | +| 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 created `Append | Structured` -(`files/ShieldPanel.cs:164`), reset at `:366` before the dispatch that appends, copied at -`:369` into offset `sizeof(uint)` — which is `instanceCount` in -`GraphicsBuffer.IndirectDrawArgs` — and every buffer is released by `ReleaseAll` on both -`OnDisable` and the head of `Rebuild`. The probe's readback of -`argsFill instanceCount=253` against `cells=253` confirms the counter path end to end. -**(probe)** +**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)** -Per-frame cost, measured shape: 253 cells → 1 inject dispatch per queued hit, 12 dispatches -for six wave substeps plus their echoes, 1 fracture, 1 update, 1 `CopyCount`, 1 buffer -upload, 1 indirect draw. At the scene's panel radius of 10 with `cellSize 0.35` expect -roughly four times the cells and the same dispatch count. Nothing here is close to a -budget problem in a test scene; the only per-frame CPU waste is D12. - -## 6. Subtraction ledger - -| Cut | Removed | Added | Deps/targets | -| --- | --- | --- | --- | -| 1 | `ShieldPanelTester` (~38 lines), 2 args buffers, 3 draw bools, 2 draw calls, `ExecuteAlways` | 6 files (~5.4k lines) | none added; no package, no pipeline, no asmdef | -| 2 | — | 1 file (~35 lines) | none | -| 3 | the one-shot editor script, in the same commit | 1 scene object, 1 component instance | none | -| 4 | — | ~5 lines changed | none | - -Net: strongly additive, and it should be. The capability bought is a simulation that does -not exist in the tree in any form; nothing currently in the repo can be deleted in exchange -for it, and the only existing machine it might replace — `FieldDriver`'s absorb visual — is -the operator's call in Q2, not a subtraction this map may assume. - -## 7. Operator questions +## 8. Operator questions Most blocking first. -**Q1 — Does the first landing have to fracture?** §1.8 says no energy breaks a cell at the -supplied defaults, and two mechanism defects (D9, D10) bound what tuning alone can reach. -A: land Cuts 1-3 now, ship a visibly-materialising panel with a real stress wave and no -fracture, and take Cut 4 as its own pass with its own probe. B: hold the whole thing until -Cut 4 lands too, so the first thing seen is the effect as designed. **Recommended: A.** -Wiring is the thing ruled on, it is verifiable on its own, and separating it means the -tuning pass gets judged against a rig that already works instead of against itself. The -cost is one operator look that ends in "nothing broke," which is the honest state. +**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 — Does the panel replace the field shield's absorb visual or sit alongside it?** -`FieldDriver.Absorb` (`FieldDriver.cs:158`) feeds the existing ellipsoid ripple, and the -binder subscribes every `IAbsorbPresenter` it finds, so both can run from the same event -with no code change. A: both, for comparison. B: panel only — remove `IAbsorbPresenter` -from `FieldDriver` and let its hit buffer machinery (`:76,105-132,204-217`) go with it. -**Recommended: A for the first landing, then B as a deliberate deletion once the panel has -been looked at.** Keeping both permanently would be two absorb visuals with no rule about -which owns the read; that is exactly the split this map should not create. +**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 fracture pattern does each damage type get, and where is it authored?** Six -of each, no natural ordering. Proposed table: Kinetic→Radial, Optical→Lance, -Electric→Star, Thermal→Crystal, Corrosive→Fizzle, Ionizing→Spiral. Authoring: A: a -serialized `FracturePattern[]` on the presenter, indexed by `(int)DamageType`. B: a switch -in code. C: gameplay settings or item data. **Recommended: A.** It is presentation choice, -it belongs with the presentation, and the operator can retune it without a compile. C is -wrong today: the absorb capability owns no data yet, and `CapabilityEvents.cs:17-20` -explicitly parks that. +**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 — How is the scene edited?** A: a one-shot editor script run in batchmode and deleted -in the same commit. B: hand-edited scene YAML. C: the operator clicks it together in the -editor. **Recommended: A.** B is the failure mode that produced `1428ada3`; C costs the -operator's hands for something deterministic. If the operator would rather keep the script -as a menu item, say so — that turns it into a standing second authority over the rig and -should be a deliberate choice, not a leftover. +**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 — `[ExecuteAlways]`?** A: drop it; the panel is inert until play mode. B: keep it, so -the lattice is visible while authoring the scene. **Recommended: A**, with `Rebuild` kept -on the context menu so a deliberate editor preview is still one click. Keeping it runs a -full GPU simulation in every open scene view forever, for authoring convenience that a -context menu already provides. +**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. +**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?** The supplied `drawFill`/`drawOutline`/`drawConduits` bools -die with the single-call draw (probe §1.5). A: gone. B: back as shader uniforms that zero -each pass's alpha. **Recommended: A**, until someone wants them; B is two minutes' work -the day that happens, and the toggles have no consumer today. +**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` cannot reference -`Assembly-CSharp`, so no unit test can see the presenter as mapped. A: no asmdef; pin the -rule with the batchmode probe in Cut 2, which checks the whole path from published event -to GPU state. B: add `Aetheria.ShieldField.asmdef` over the six files plus the presenter, -referencing `Aetheria.Shared.Unity`, and let `Tests` reference it. **Recommended: A.** B -buys a test of the arithmetic in a one-line method while adding a compile boundary and a -reference edge to a project that has two test files total; the probe checks the thing that -would actually break, at the layer it would break. +**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. -**Q8 — Which tiling is the default?** The component ships `PenroseP3` with -`mergeRhombs`. `Hex` is what the probe exercised and the only one whose adjacency was -verified. `Voronoi` costs D7 at init and `TriHex` carries D16. A: Penrose, as authored. B: -Hex for the first landing, Penrose once the rig is looked at. **Recommended: B**, then move -to whatever looks right — this is a look-at-it decision, not a correctness one. +## 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.