Aetheria/docs/shield-panel-cut.md
Meta Vens 1fdf815111 Map the shield panel onto the built-in pipeline and the absorb event
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 04:47:36 +02:00

494 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Shield Panel: GPU Fracture Simulation Cut Map
Date: 2026-09-18
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/<name>:<line>` against the operator's drop at
`<scratch>/files/`.
Mechanism claims marked **(probe)** were run, not reasoned. The probe was a throwaway
Unity 6000.3.24f1 project in scratch (`<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: `<scratch>/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.
## 0. Target
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.
Ends:
- 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.
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.
## 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`.
**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.
**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.
**Per-file changes (against the supplied files):**
- `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`.
- `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.
**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
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.
**Verification:**
- 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. The absorb presenter
- **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).
**Shape:**
```
public class ShieldPanelPresenter : MonoBehaviour, IAbsorbPresenter
{
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
public void Absorb(AbsorbEvent e)
{
if (Panel == null) return;
Panel.Hit(e.Position.ToUnity(), e.Direction.ToUnity(),
e.Magnitude * EnergyScale, PatternFor(e.DamageType), HitRadius);
}
}
```
`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.
`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.
**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.
**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`.
## Cut 3. The scene rig
- **Repo/branch:** same. Depends on Cuts 1-2.
- **Touches:** `Assets/Scenes/FieldShieldTest.unity` only.
**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.
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.
**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.
**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.
**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.
- 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.
## Cut 4. 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.
**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.
**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.
- 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).
## 5. Defects in the supplied code
Confirmed by compiling and running them, not by reading. D1-D4 are fatal; the cut does not
exist without them.
| # | Where | Finding |
| --- | --- | --- |
| D1 | `files/TilingBuilder.cs:214` | CS0136: `dist` shadows the `dist` at `:225`. The file 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. |
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)**
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
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.
**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.
**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.
**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.
**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.
**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.
**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.
**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.