60 KiB
Shield Panel: Hard-Light Interceptor Cut Map
Date: 2026-09-18 (revised; supersedes the flat-panel map committed at 1fdf8151)
Status: Imagination pass, cut map. Nothing here has landed. Repo anchors are against
codex/item-provenance HEAD 482229d1 — the Addressables cuts (67cbf34b, 482229d1)
landed after the first draft, so every file:line below was re-checked against the current
tree. The six incoming sources are still not in the repo; they are anchored as
files/<name>:<line> against the operator's drop at <scratch>/files/.
Claims marked (probe) were measured in Unity 6000.3.24f1, Direct3D12, srp=builtin,
batchmode with graphics, in a throwaway project holding only the six files and a ported
shader. Nothing was built in the Aetheria tree and no Unity session was opened against it.
The probe project is scratch and goes with the session; §1 and §2 record what it proved so
nobody has to re-run it.
0. Operator rulings, 2026-09-18
These close four of the previous pass's forks and change the shape of the thing. The old design — one permanent flat disc standing in front of the hull — is history; see §9.
R1 (was Q2) — alongside, not instead. "It's a different way of doing shields." The panel
effect runs next to FieldDriver's absorb visual, not in place of it. Both subscribe to the
same AbsorbEvent stream; neither owns the other. The previous recommendation ("A for the
first landing, then B as a deliberate deletion") is withdrawn — B is not coming.
R2 — the shield is an interceptor. "It's a shield that intercepts hits by spawning those hard-light panels. I'd set the normal of the panels to the surface of the collision ellipsoid." So a panel is short-lived and per-interception: it does not exist until something is intercepted, it is posed at the intercept point with its normal along the ellipsoid surface normal there, and it dies. Not a permanent panel. Not a wrapped shell. Its size comes from the shield item, not the hull — the hull decides where the panel appears, the item decides how big it is.
R3 — every shield effect is a drop-in behavioural replacement. A shield presentation takes absorb events, presents them, and decides nothing. The contract is behavioural: coverage is expressed through the envelope, not through literal geometry. A presentation is allowed to be a wrapped shell, a flat plate, or a swarm of spawned patches, as long as it answers the same events over the same envelope.
R4 — the ellipsoid envelope is standardised and authored on the hull prefab. "The field shield already has the ellipsoid, we'd just be standardizing that. Ship prefabs would need to define their ellipsoid envelope."
R5 (was Q3) — fix Penrose in this cut. Operator: "definitely fix penrose". D18
(Penrose ignores panelRadius) is a Cut 1 defect, not a follow-up: R2 makes panel size the
item's to set, so a tiling that cannot be sized cannot ship. Hex remains the fallback while
the fix is verified, and D19 (TriHex yields no dual edges) and D7 (Voronoi is 15 s at r10)
stay recorded, not fixed.
R6 (was Q2) — the envelope is derived from the existing transform, not authored as centre and radii. Operator: "derived; the simulation doesn't even use this collision, the hits we end up showing will be 'fake'". Two consequences worth holding on to:
- The envelope has no second copy to drift from the
Shieldcollider, which is the whole point of collapsing the two implicit ellipsoids. - Panel placement is cosmetic. The simulation does not resolve hits against this
geometry; fire control rolls decide what happens (
docs/three-gates-scope.md). The intercept point is where the presentation says the shot arrived, and nothing downstream may read it back as truth.
Q1 (does the first landing have to fracture) is still open and still first; see §8.
Cut 1 landed 2026-09-18 (
a935b965sources+port+defects,ff8b2ff7probe and its mutation harness). Batchmode: 0 C#, shader and compute errors; only D15's uninitialised- variable warning survives. D1, D3, D4, D6, D12, D18 fixed; D2/D5 collapsed the draw to one call; D7 and D19 recorded; D9/D10 to Cut 5, D17/D20 to Cut 3. The fix for D18 retiredpenroseIterationsandsubdivisions. A repo-specific trap the map missed:UI/ContextMenu.csshadowsUnityEngine.ContextMenu, so the attribute needs qualifying.Open, found in Cut 1's own numbers (D21): Penrose scales with radius now, but not with
cellSizethe way Hex does — at r1.5/cellSize 0.35 it yields 460 cells where Hex yields 19, and 8050 at r10 against Hex's 979. Iteration count is quantised (each substitution divides edge length by phi), so a panel can only land near the requested density, and it currently lands an order of magnitude denser. Cut 3 sizes panels from the item (R2) and pools them (§2.4), so settle this before pooling: either the count is wrong, orcellSizemeans something different for a quasiperiodic tiling and the item's knob must say which.
Cut 2 landed 2026-09-18 (
a454a5a7, fallback fix7848a8a8). One owner,Gameplay/ShieldEnvelope.cs; the hand-rolled projections inFieldDriverandShieldManagerare gone. Radii are measured from the carrier's collider mesh, not assumed: the map'slossyScale * 0.5guess was wrong, and Shield.prefab's icosphere collider is not even isotropic.SurfaceNormalscales the gradient by the inverse oflossyScale(a normal is a covector); the first implementation usedTransformDirectionand a finite-difference probe caught it 5-20% out. It diverges fromnormalize(p)by dot 0.674 on the Longinus flank, which is why the panel needs it and the two old consumers did not.Carriers without the component behave exactly as before Cut 2, warning once per object. Self sent the first attempt back: its fallback degraded shield hits to a fixed direction until the operator edited prefabs, which is a regression on working behaviour.
Open (D22):
FieldDriver's two branches are not interchangeable in general —ProjectToSurfacereturns a world point, the legacy fallback stays local because that is what the shader eats. They agree only for a carrier at the origin with identity rotation, which is the onlyFieldDrivercarrier in the tree. Settle before a second one exists.
Cut 3 landed 2026-09-18 (
0ef88419,0e2fe6dc,5ae5c518).ShieldInterceptorposes a pooled panel per absorb event on the envelope, reusingPrototype. D17/D20 fixed (a panel absorbs the hit that spawned it, and the gate stays open after), D21 fixed (RoundnotCeil+1on the substitution count): Penrose is now 2.1-4.7x Hex's cell count at the same radius and cell size, down from ~24x, and that residual is the quantisation floor of "nearest edge length", not a knob. Reuse strikes a live panel withinPanelRadius * ReuseFractionwithout resetting it, so temper erosion survives; at the cap (12) the oldest panel is recycled in place.Found beyond the map:
OnEnable/OnDisablewould have rebuilt the tiling and released every buffer on each pooled activation, which would have made pooling cost more than rebuilding and quietly voided §2.4's whole argument.Two yields with a background run still going cost a round trip each; the wait rule now names the mechanism (
~/.claude/skills/eureka/references/briefs.md).
Ruling R7 (operator, 2026-09-18): a panel's rim is held, not free. "held is probably nicer even if free is more accurate". The emitters projecting the panel hold its edge, so the rim stops being a tension source (
ShieldSim.compute'sopenWghost term) and the only spall left comes from the shot itself through the plate. Cracks radiate from the impact and shards leave where the hit landed, and panel size stops being a fragility multiplier. A broken cell's faces stay free: that reflection is the dicing cascade and it survives.Operator play evidence, 2026-09-18, on the rigged scene: a panel spawns, is reused, and spiderwebs, but only shatters when hits are spammed near the rim — which is the free edge reflecting compression back as tension exactly as designed, and confirms the wave, the reflection and the temper erosion all work. The interior spall source (the through-thickness echo) is too weak or mistimed to compete. That, D9 (velocity unclamped, so hard hits saturate in compression) and D10 (fracture evaluated once per frame while the wave integrates several times) are Cut 5's targets.
1. What the repo actually has today
The re-probe found the thing the first pass missed: the ellipsoid envelope already exists twice, in two unconnected places, and neither is typed.
Authority A — FieldDriver's transform. The ellipsoid is implicit in the field object's
scale. Assets/Scripts/Gameplay/FieldDriver.cs:125 projects a hit onto it with
normalize(transform.InverseTransformPoint(position)) * transform.localScale; :201 feeds
_InverseScale to the shader; :297-299 scales the tendril vectors by it. The only carrier
in the whole tree is the cubesphere Variant prefab instance in
Assets/Scenes/FieldShieldTest.unity, whose localScale is (4, 3, 12) at
Assets/Scenes/FieldShieldTest.unity:948-959. FieldDriver is on no ship prefab at all
— its script guid appears in exactly one asset file, that scene, at :1042.
Correction to the previous pass: the old map said this object was scaled
(10, 10, 20)(old §"Cut 3"). It is(4, 3, 12). Every geometry number in the old Cut 3 was wrong.
Authority B — the Shield prefab instance on each ship. Assets/Content/Prefabs/Shield.prefab
has base localScale (100,100,100) at :212 and a convex MeshCollider at :218-231. Each
ship overrides it non-uniformly, and that override is the ship's envelope today:
| Ship | localScale override | localPosition | effective radii |
|---|---|---|---|
Longinus (Assets/Content/Prefabs/Ships/Longinus.prefab:1832-1843, pos :1847-1858) |
(395.35, 235.47, 990.38) | (0, 0.73, 1.32) | ≈ (3.95, 2.35, 9.90) |
Djinni (Assets/Content/Prefabs/Ships/Djinni.prefab:7378-7389) |
(857.93, 424.00, 1036.26) | (0, 1.75, -1.06) | ≈ (8.58, 4.24, 10.36), pitched 10° |
ShieldManager.ShowHit (Assets/Scripts/Gameplay/ShieldManager.cs:73-78) already does the
same normalise-and-project as FieldDriver.cs:125, against this transform instead:
normalize(shield.transform.InverseTransformPoint(point)). Two implementations of one idea,
neither aware of the other. The test scene's (4,3,12) is visibly an eyeball of Longinus's
(3.95, 2.35, 9.90) — which is the tell that this was always one concept.
So the standardisation R4 asks for is not a new invention. It is a collapse of two existing duplicate authorities, and the panel is its third consumer rather than its reason.
No data-side size exists. HullData (Assets/Scripts/ServerShared/ItemData.cs:493-533)
carries Hardpoints, Prefab, HullType, GridOffset, Armor, Drag, CanTow, Shape
— no radius, no extents. Nothing in Assets/Scripts ever assigns a shield or field object's
localScale; ZoneRenderer.cs:295 instantiates the hull prefab and applies no scale at all.
The prefab is the only place the envelope can live today. For the record, and as a
follow-up rather than a fork: this belongs in the catalog next to HullData, for the same
reason hardpoints do — HardpointData.Transform
(Assets/Scripts/ServerShared/ItemData.cs:541) is a GameObject name string matched against
prefab child names at runtime (EntityInstance.cs:242,257,268, ShipInstance.cs:69-70),
which the operator has called unfortunate. The envelope would inherit the same unfortunateness
by living on the prefab. It should move when hardpoints move, in that cut, not this one.
The real absorb publisher does not exist, but its hook does. PublishAbsorb has exactly
one caller and it is test-only: Assets/Scripts/UI/FieldTester.cs:76, from a
ClickableCollider.OnClick. The real damage path is entirely separate — Entity.IncomingHit
/ HullDamage (Assets/Scripts/ServerShared/Entity.cs:126,131), published from
EntityInstance.cs:312,333. But the real shield-hit visual path already converges on one
method: ShieldManager.ShowHit, called from ShieldManager.cs:65 (collision) and six
weapons — ConstantLaser.cs:83, ConstantLightning.cs:62, GuidedProjectile.cs:162,
HitscanEffect.cs:40, Laser.cs:49, Lightning.cs:44. That is where a real
PublishAbsorb belongs, and it is a later cut, not this one. Naming it here so nobody
invents a second bridge.
The pool already exists. Assets/Scripts/Prototype.cs is the repo's pooling helper:
Instantiate<T>() 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)
- The shader ports to built-in with three substitutions: drop
"RenderPipeline"="UniversalPipeline"from the SubShader tags, replace the URPCore.hlslinclude withUnityCG.cginc, rename threeTransformWorldToHClip(toUnityWorldToClipPos(. Resultshader supported=True, messages=0. No other URP symbol is used. (probe) - Indirect drawing works on built-in.
Graphics.RenderPrimitivesIndirectwithRenderParams, aMaterialPropertyBlockcarryingGraphicsBuffers, and an append-buffer counter copied in byGraphicsBuffer.CopyCountrendered the panel. (probe) RenderParamshas noshaderPassin 6000.3.24f1.files/ShieldPanel.cs:410,415,420do not compile. (probe)- A multi-pass material renders every pass from one call, and pass selection is not available at this API. (probe)
- One args buffer sized to the widest pass is equivalent — pixel-identical output to the
three-call version (
litPixels=43929both ways). (probe) centroidis reserved in the compute compiler.CellStatic.centroid(files/ShieldCommon.hlsl:18) must becomecenter. (probe)- One surviving shader warning:
Warning 172: potentially uninitialized variable (BuildLine), Conduit vertex program. Warning only. (probe) - The supplied tuning never fractures anything. Hex,
panelRadius 5,cellSize 0.35, 253 cells, head-on Radial hit, 60 frames:broken 0/253at 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
panelRadiusentirely — 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 GraphicsBuffers + a Material). Hex, 20 iterations each. (probe)
| cells | Rebuild ms | ResetSim ms | ratio |
|---|---|---|---|
| 85 | 1.32 | 0.011 | 123× |
| 253 | 3.33 | 0.008 | 428× |
| 979 | 20.91 | 0.008 | 2686× |
Recycling is free and flat — 8 microseconds, independent of cell count, because it is two GPU dispatches and nothing else. Building is 1.3–21 ms and scales with cells.
Decision, from the number and not from taste: prebuild. One tiling per shield item, built
once at load; a preallocated pool of armed ShieldPanel instances; interception costs a
ResetSim and a transform write. Building a tiling at interception time would drop a 1–21 ms
spike into the exact frame the player is being shot in, which is the worst possible frame to
spend it, and buys nothing — every panel of one shield item is the same tiling.
3. Target
A shield item's presentation is an interceptor. On each absorbed hit it spawns a hard-light panel at the intercept point on the ship's ellipsoid envelope, normal along the envelope's surface normal there, sized by the item. The panel materialises outward from the strike, takes that hit and any subsequent hit that lands inside it, and dies.
Invariants:
- One owner of the envelope.
ShieldEnvelopeis the only thing that answers "where is the surface, and which way does it face".FieldDriver,ShieldManagerand the panel presenter all read it and none of them recompute it. - One owner of the hit.
ShieldPanel.Hitis the only entry into a panel's simulation, and its presenter is the only caller. - One event, one strike. An
AbsorbEventproduces 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 atCapabilityEvents.cs:9-20.) - Presentations do not see each other.
FieldDriverand the panel presenter both subscribe and neither reads the other (R1). The binder already supports this without change:CapabilityPresentationBinder.cs:32-36subscribes everyIAbsorbPresenterit finds. - No second render pipeline, no
Unity.Mathematics. Unchanged from the first pass;ProjectSettings/GraphicsSettings.asset:41is stillm_CustomRenderPipeline: {fileID: 0}. - No allocation at interception. The pool is preallocated and the tiling is prebuilt. An
interception costs a
ResetSimand a transform write (§2.4).
Cut 1. Land the sources, compiling, on built-in
Unchanged in substance from the committed pass, minus the parts R2 made pointless. Depends on
nothing; touches no file the Addressables cuts moved (nothing here goes near
Assets/Content or Assets/Resources).
Location. Assets/Shaders/Compute/ShieldField/, matching
Assets/Shaders/Compute/Lightning/ and Slime/, which each hold their .compute,
.shader, .cginc and driver .cs together. Colocation is load-bearing:
#include "ShieldCommon.hlsl" resolves relative to the including file (probe). No asmdef
— the files land in Assembly-CSharp with everything else in Assets/Shaders/Compute/*.cs.
The ShieldField namespace collides with nothing.
Deletes first:
files/ShieldPanel.cs:450-487—ShieldPanelTester. The standalone click harness the operator ruled against, and it cannot run anyway:ProjectSettings/ProjectSettings.asset:797isactiveInputHandler: 1(new Input System only), soInput.GetMouseButtonDownthrows.files/ShieldPanel.cs:90—argsOutline,argsConduitand theirMakeArgscalls at:172-173; thedrawFill/drawOutline/drawConduitsbools at:68-70and their branches at:408-422. Replaced by one args buffer and one draw (§2.1.5).files/ShieldPanel.cs:9—[ExecuteAlways]. With it,Updateruns the whole simulation and every dispatch in every open scene view, andOnEnablerebuilds the tiling on every domain reload. KeepRebuildon the context menu.
Per-file changes:
ShieldCommon.hlsl:18—float2 centroid;→center. Every.centroidinShieldSim.computeandShieldPanel.shaderfollows. Required to compile (§2.1.6).ShieldCommon.hlsl:31— addfloat pad2;, takingCellStaticto 16 floats.TilingBuilder.cs:27— addpublic float pad2;, initialise at:242. Today the struct is 15 floats (60 bytes) whileStrideat:28claims4 * 16, andSetDatathrows on the mismatch (probe). Fix both sides or neither.TilingBuilder.cs:214,221— rename the innerdisttonbrDist. CS0136 against thedistat: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— oneargsFill = MakeArgs((uint)(MaxV * 2 * 6));.ShieldPanel.cs:369-371— oneCopyCount(bVisible, argsFill, sizeof(uint)).ShieldPanel.cs:408-422— oneRenderPrimitivesIndirect, norp.shaderPass.ShieldPanel.cs:296-298— hoist the per-framenew uint[] { 0u, cap }to a cached field.- Defect fixes D3–D8 from §7, each named in the commit.
Authority map:
- Owner:
ShieldPanelowns one panel's simulation and its GPU buffers.TilingBuilderowns adjacency, areas and ghost weights.Tilingsowns 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
ShieldPaneltouches its buffers.ShieldPanelTesteris deleted rather than kept as a second hit source. - Deletion line:
ShieldPanelTester, two args buffers, three draw bools, theshaderPasswrites, and[ExecuteAlways]are gone before Cut 2 is written.
Verification: batchmode compile of the Aetheria project, zero errors.
ShaderUtil.GetShaderMessages / GetComputeShaderMessages report zero errors (the §2.1.7
warning may remain). Negative:
rg "UniversalPipeline|TransformWorldToHClip" Assets/Shaders/Compute/ShieldField is empty;
rg "shaderPass" Assets is empty. No operator look yet.
Cut 2. ShieldEnvelope — collapse the two implicit ellipsoids into one
This is the R4 cut, and it is a teardown, not an addition. Two authorities (§1 A and B) become one reader, and both existing consumers are converted to it before the panel exists. Depends on nothing; can land in parallel with Cut 1.
Adds: Assets/Scripts/Gameplay/ShieldEnvelope.cs, one component.
Shape, and why this shape. The component is a typed reader of its own transform, not a new store of numbers:
public class ShieldEnvelope : MonoBehaviour
{
// Radii and centre come from this transform. They are not authored twice.
public Vector3 Center => transform.position;
public Vector3 Radii => transform.lossyScale * 0.5f; // see note on the 100x base
public Vector3 ProjectToSurface(Vector3 worldPoint); // local, normalise, scale, back
public Vector3 SurfaceNormal(Vector3 worldSurfacePoint); // gradient, see below
}
The operator asked for "a ShieldEnvelope component with centre + three radii, or whatever
the Body argues for". The Body argues for derived, not authored, and the reason is
ownership: the two ships already have their envelope authored, as the Shield prefab
instance's transform (Longinus.prefab:1832-1858, Djinni.prefab:7378-7389), and the convex
MeshCollider on that same object (Shield.prefab:218-231) is already driven by it.
Authoring centre and radii as separate serialized fields next to a transform that also decides
the collider would create a second truth about the same surface and a silent way for them to
disagree — the split this map exists to avoid. One authority, three readers.
The cost of that choice, stated plainly: the envelope is only as expressive as a transform, so it is an ellipsoid with a rotation and no more. That is exactly what R2 asked for, and if a hull ever needs a non-ellipsoid envelope this component is the place that changes.
Radii must account for Shield.prefab's (100,100,100) base (Shield.prefab:212) and for
whether the mesh at Shield.prefab:218-231 is a unit-diameter or unit-radius sphere. Hands
must read that mesh's bounds and derive the factor rather than assuming 0.5 — get it wrong
and every panel is posed at twice or half the right distance, which looks like a bug in the
panel and is not.
SurfaceNormal is not normalize(p). For an ellipsoid with radii (a,b,c) centred at
the origin, the outward normal at a surface point p is
normalize(p.x/a², p.y/b², p.z/c²), then back to world through the transform's rotation.
normalize(p) is only correct for a sphere, and Longinus's envelope is 4:1 in aspect, so the
error is large and visible — panels would visibly tilt wrong along the flanks. Both existing
consumers get away with normalize today because they use it to pick a direction on the
surface, not a normal to it. The panel needs the normal (R2) and this is where the
distinction is paid for.
Deletes / demotions — the part the model will want to skip:
FieldDriver.cs:125stops computing its own projection. It readsenvelope.ProjectToSurface(position).FieldDriver.transform.localScaleis no longer an owner of the envelope; it is display geometry for the field mesh only.FieldDriver.cs:201and:297-299readenvelope.Radiirather thantransform.localScale. If aShieldEnvelopeis absent,FieldDrivermust log once and fall back to its own transform — explicitly a compatibility path, and it delegates rather than keeping its own opinion.ShieldManager.cs:76stops computingnormalize(InverseTransformPoint(point))and reads the envelope.ShieldAnimation.Directionkeeps 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:
ShieldEnvelopeowns 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:125andShieldManager.cs:76are the two that must stop, and the negative check below pins it. - Shared paths: collision hits (
ShieldManager.cs:65), the six weapon call sites, the test scene's clicks, and the panel presenter all resolve the surface through this one method. - Deletion line: the two hand-rolled projections go before the panel presenter is written.
Verification:
- test:
ShieldEnvelopeis pure math on a transform, so this one can be unit-tested — butTests.asmdefcannot referenceAssembly-CSharp(see Q7), so it is a batchmode probe: for each of Longinus's and Djinni's envelopes, assertProjectToSurfaceof a point outside lands on the ellipsoid (Σ(pᵢ/rᵢ)² == 1within epsilon) and thatSurfaceNormalat 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/Gameplayreturns no shield-surface projection outsideShieldEnvelope.cs— specifically thatFieldDriver.cs:125andShieldManager.cs:76no longer contain one. - operator:
FieldShieldTestbehaves exactly as it does today. This cut is a no-op to look at, and if anything changes visually, something is wrong.
Cut 3. The interceptor presenter and the panel pool
Depends on Cuts 1 and 2. This is where R2 lives.
Adds: Assets/Scripts/Gameplay/ShieldInterceptor.cs, one component,
MonoBehaviour, IAbsorbPresenter. Adds no pool — Assets/Scripts/Prototype.cs is the
pool (Instantiate<T>() at :41, ReturnToPool() at :80).
Shape:
public class ShieldInterceptor : MonoBehaviour, IAbsorbPresenter
{
public ShieldEnvelope Envelope;
public Prototype PanelPrototype; // a prefab carrying one ShieldPanel
public int MaxLivePanels = 12; // see the budget in §2.3
public float PanelRadius = 2f; // the SHIELD ITEM's size, not the hull's (R2)
public float EnergyScale = 1f;
public FracturePattern[] PatternByDamageType;
public void Absorb(AbsorbEvent e) { /* project, reuse-or-spawn, strike */ }
}
The event is enough, and here is exactly what it carries.
AbsorbEvent (Assets/Scripts/ServerShared/CapabilityEvents.cs:21-35) has float3 Position,
float3 Direction, float Magnitude, DamageType DamageType. The publisher
(FieldTester.cs:76) passes hit.point and ray.direction — both world space, position
being the raycast's contact point on the field's MeshCollider. So:
- Intercept point =
Envelope.ProjectToSurface(e.Position.ToUnity()). The projection is needed and is not optional:hit.pointlands on whatever collider was struck, which is the field mesh in the test scene and will be the hull or theShieldcollider in the real path — none of which is guaranteed to be on the envelope. - Which projection: normalise-and-scale, matching
FieldDriver.cs:125andShieldManager.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;Directionalone does not give one. If the operator later wants true ray-ellipsoid,AbsorbEventhas 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).ShieldPanelbuilds its disc in local XY with its normal on+Zand draws throughmpb.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
Shieldinstance's scale is non-uniform (395, 235, 990) — parenting under it would ride that shear into_PanelToWorldand 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 fromPatternByDamageTypeindexed by(int)e.DamageType, falling back toRadial.
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):
- Build an envelope, an interceptor with
MaxLivePanels >= 2, and a pool. - Publish an
AbsorbEventat point P. Assert exactly one panel is live. - Step frames until that panel is grown (
growth >= 0.6on the cells near the centre — read backbState). - Publish a second
AbsorbEventat P + a small offset well insidePanelRadius. Assert the live-panel count is still one, and assert the struck panel's minimumtemperhas 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. - 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<ShieldPanel>(), pose it,ResetSim(), set_PanelSpawnTimeto 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 —
spawnDelayis 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-
shardLifeor has decayed.KUpdatealready 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 (
MaxLivePanelsreached): 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:
ShieldInterceptorowns the live-panel set, the spawn/reuse decision, and each panel's pose. It owns nothing about the material. - Inputs: one
AbsorbEvent; oneShieldEnvelope; its authored fields. - Outputs:
Prototype.Instantiate/ReturnToPoolcalls, transform writes,ShieldPanel.Hitcalls. - Derived state: the live list and each panel's arm time. No per-hit state beyond that —
ShieldPanelalready queues hits (files/ShieldPanel.cs:82). - Forbidden writers: nothing else may spawn, pose, or return a
ShieldPanel. The interceptor never touchesCapabilityEvents(the binder owns subscription,CapabilityPresentationBinder.cs:32-36), never readsFieldDriver(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 sameAbsorbmethod, because both go throughCapabilityEvents.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 == nullorPanelPrototype == nulldoes not throw. Plus: publishingMaxLivePanels + 3widely-separated events leaves exactlyMaxLivePanelslive and issues no allocation after the firstMaxLivePanelsspawns. - negative:
rg "ShieldPanel" Assets/Scripts --glob '!ShieldInterceptor.cs'finds no second caller ofHit,InstantiateorReturnToPoolon 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:
ShieldEnvelopeon the field object (Cut 2 already adds it).ShieldInterceptoron the field object, next toFieldDriver,ClickableCollider(:1077-1088) andCapabilityPresentationBinder(:1089-1100), because the binder finds presenters withGetComponentsInChildrenon its own object (CapabilityPresentationBinder.cs:32). BothFieldDriverandShieldInterceptorare then subscribed — which is R1, achieved with no code change.- A new
ShieldPanelprefab carrying aPrototypeand aShieldPanel, withsimandpanelShaderassigned.PanelRadiuson the interceptor, not on the prefab — the interceptor pushes it, because it stands in for the item. PanelRadiusstarting 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. AtcellSize 0.35that 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
ShieldInterceptoror an envelope at runtime.FieldTesteris not modified — it already publishes the events, and giving it knowledge of the panel would re-split the fork-L authority. - Deletion line: the editor script, before the commit closes.
Verification:
- test: batchmode headless open of
FieldShieldTest.unityasserting exactly oneShieldInterceptor, oneShieldEnvelope, a non-null prototype, and that the binder resolves bothFieldDriverand the interceptor asIAbsorbPresenter. - operator — this is the first look, and here is exactly what to look at:
- 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.
- 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.
- 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
SurfaceNormalgradient — anormalize(p)normal is visibly wrong here and correct at the nose, so checking only the nose proves nothing. - 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.
- Click many times, far apart, quickly. Count never exceeds
MaxLivePanels; the oldest patch is replaced rather than the newest being dropped. - Let them all fade. Every panel disappears; nothing is left behind.
- Whether anything shatters is Cut 5, and at this point the honest answer is no.
Cut 5. Make a hit actually break the panel
Depends on Cut 4 and on ruling Q1. A tuning and mechanism pass, not a wiring pass. §2.1.8 shows no energy fractures the panel at the supplied defaults; do not tune by eye first, because two mechanism defects bound what tuning can reach.
ShieldSim.compute:226-232(D9) —uis clamped to±_StressClamp,vis not. A hard hit drivesvpast what the clamp permits,upins in compression, and the field never swings back into tension. That is the measured collapse above energy 60. Clampvtoo, or normalise the injection so energy maps to a bounded velocity.ShieldPanel.cs:301,359(D10) —_Dtis set tofixedSubstepDtbefore the injection loop and not restored until afterKFractureis dispatched, soKFractureintegrates damage and temper erosion with a substepdtwhile running once per frame. Damage accrues ~6× slow. Either dispatchKFractureinside 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 framedt(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,KWaveStepandKFractureall skip cells withgrowth < 0.6. A panel spawned by an interception is atgrowth = 0in 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_PanelSpawnTimeback-dated so the centre is already grown; hold the strike inpendinguntil growth passes the gate (the queue atfiles/ShieldPanel.cs:82already 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,56and the interceptor'sEnergyScale.
Verification:
- test: batchmode energy sweep, readback of
bState, asserting weak energy leavesbroken == 0while 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 == 0withminTemper == temperInit(the saturation signature). And: a panel struck in its spawn frame shows non-zero|v|in the cells near the centre — the D17 negative. - operator: strong hits dice the patch. Repeated hits on the same spot break through on a later hit than the first — this is the payoff of the Cut 3 reuse rule and the temper mechanism together, and it is the whole reason the reuse rule exists.
6. Cost and budget
Build budget. One tiling per shield item, prebuilt at load. Hex at the interceptor's
working size costs 0.91 ms (85 cells, r3) to 2.30 ms (253 cells, r5), once, ever
(§2.2). The pool's ShieldPanel instances are preallocated: 32 instances at 253 cells cost
111 ms to construct (§2.3), which is a load-time cost and must not happen during play.
Frame budget. ~0.12 ms CPU dispatch issuance per live panel, flat in cell count (§2.3).
MaxLivePanels = 12 is ~1.4 ms/frame; 16 is ~2.0 ms. Cap at 12 for one ship. The cap is
the real budget knob; cell count is not, and raising cellSize to "optimise" would only cost
visual density for nothing.
Interception cost. ResetSim + transform write = 8–11 µs (§2.4), flat. Free.
Static-buffer sharing, named and deliberately deferred. bCells, bVerts and bEdges
are byte-identical across every panel of one shield item, so the pool currently holds N copies
of the same data — at 253 cells that is ~40 KB × N of pure duplication and N redundant
uploads at load. Hoisting them into one shared owner is the obvious subtraction. It is not
in this map because it changes ShieldPanel's buffer ownership, which §2.3 says buys no frame
time (the cost is dispatch count, not data), and Cut 1's authority map says ShieldPanel owns
its buffers. Do it as its own cut when the pool is real and the duplication is measurable,
not speculatively.
7. Defects in the supplied code
Confirmed by compiling and running, not by reading. D1–D4 are fatal to compilation; D17 is fatal to the interceptor design specifically.
| # | Where | Finding |
|---|---|---|
| D1 | files/TilingBuilder.cs:214 |
CS0136: dist shadows the dist at :225. Does not compile. (probe) |
| D2 | files/ShieldPanel.cs:410,415,420 |
RenderParams.shaderPass does not exist in 6000.3.24f1. (probe) |
| D3 | files/TilingBuilder.cs:28 vs :12-27 |
Stride = 4 * 16 but the struct is 15 floats; SetData throws. (probe) |
| D4 | files/ShieldCommon.hlsl:18 |
centroid is reserved in the compute compiler. (probe) |
| D5 | files/ShieldPanel.cs:411-421 |
Pass selection does not exist at this API; the three per-pass args buffers cannot work as designed, independent of D2. (probe) |
| D6 | files/ShieldSim.compute:38 |
Comment says _BreakBudget is a "single element"; the C# allocates two and reads index 1. A lying comment, not a bug. |
| D7 | files/Tilings.cs:302-333 |
VoronoiCells is O(n² log n). Quantified in §2.2: 134 ms at r3, 1.03 s at r5, 15.0 s at r10. Unusable. (probe) |
| D8 | files/Tilings.cs:286-288 |
Vector2.zero is both the invalid-cell sentinel and a legal centroid. idx at :292,297 is dead. |
| D9 | files/ShieldSim.compute:226-232 |
u clamped, v not; hard hits pin the field in compression and no tension reflects. The measured collapse in §2.1.8. (probe) |
| D10 | files/ShieldPanel.cs:301,356,359 |
KFracture runs once per frame but integrates with the substep dt; damage accrues ~6× slow. |
| D11 | files/ShieldSim.compute:326-330 |
_V[e.nbr] += is a non-atomic read-modify-write across threads; the cascade is nondeterministic. Fine for an effect; fatal to any test pinning an exact shard count. |
| D12 | files/ShieldPanel.cs:296-298 |
A two-element uint[] allocated every frame. Worse under the pool: this is now N allocations per frame, not one. Cache it. |
| D13 | files/ShieldPanel.cs:9 |
[ExecuteAlways] runs the full sim in every editor scene view and rebuilds on every domain reload. Incompatible with a pool — OnEnable → Rebuild would run a 1–21 ms tiling build every time Prototype.Instantiate wakes an instance (§2.4). Must go; arming becomes explicit. |
| D14 | files/ShieldPanel.cs:426-440 |
ReleaseAll does not clear pending, so hits queued before a rebuild are injected into the new tiling. Now a real bug, not a principle: a recycled pool panel would fire the previous victim's queued hit. |
| D15 | files/ShieldPanel.shader Conduit pass |
Warning 172: potentially uninitialized variable (BuildLine). Warning only. (probe) |
| D16 | files/Tilings.cs:91-116 |
TriHex interstitial triangles are placed by a separate polar construction rather than from the hex corners they share, so welding is not guaranteed. Superseded by D19 — it is worse than suspected. |
| D17 | files/ShieldSim.compute:160,191,276 |
growth < 0.6 gates injection, wave and fracture. A panel spawned at the intercept point is at growth = 0 when struck, so the hit that created it does nothing. Fatal to the interceptor design. See Cut 5. |
| D18 | files/Tilings.cs Penrose path |
Penrose ignores panelRadius — 460 cells (merged) / 890 (unmerged) at radius 1.5 and radius 10, identically. R2 says panel size comes from the shield item, so Penrose cannot express size at all. Hex can. (probe) |
| D19 | files/Tilings.cs:91-116 |
TriHex produces zero dual edges at every radius (0 edges at r1.5, 3, 5, 10). No adjacency means no wave, no fracture, no anything. It is inert, not merely suspect. (probe) |
| D20 | files/ShieldPanel.cs:360 |
_PanelSpawnTime is hardcoded 0f, so the materialisation wavefront fires once at sim time zero and never again. Must become the panel's arm time — this is the one line that turns the dead wavefront into the interceptor's spawn animation. |
| D21 | files/TilingBuilder.cs:9,119 |
Limits.MaxVerts = 8, and polygons with more vertices are silently dropped at :119. A tiling that quietly loses cells looks like a tiling bug. Log it. |
Buffer lifetime, SetCounterValue and CopyCount were checked specifically and are
correct: bVisible is Append | Structured (files/ShieldPanel.cs:164), reset at :366
before the appending dispatch, copied at :369 into offset sizeof(uint) (= instanceCount
in IndirectDrawArgs), and every buffer is released by ReleaseAll on both OnDisable and
the head of Rebuild. A probe readback of argsFill instanceCount=253 against cells=253
confirms the counter path end to end. (probe)
8. Operator questions
Most blocking first.
Q1 — Does the first landing have to fracture? (Carried over; still first, now with a second reason.) §2.1.8 says no energy breaks a cell at the supplied defaults, and D9/D10 bound what tuning alone can reach. D17 adds that the spawning hit currently injects nothing at all. A: land Cuts 1–4, ship an interceptor that spawns, poses and materialises panels correctly with a visible stress wave and no fracture, and take Cut 5 as its own pass. B: hold everything until Cut 5 lands too. Recommended: A, with one amendment to the previous pass's reasoning — D17 must be fixed in A, not deferred to Cut 5, because without it the Cut 4 operator look shows an inert patch and proves nothing about the wiring it exists to prove. Fracture can wait; injection cannot. Wiring is the thing ruled on and it is verifiable on its own.
Q2 — Is the envelope derived from the transform, or authored as explicit fields? Cut 2
recommends derived, because the Shield prefab instance transform already owns the
numbers and also drives the collider, and a second authored copy would be a second truth.
The cost is that the envelope can only ever be a rotated ellipsoid. A: derived (recommended).
B: explicit centre + radii fields, transform ignored. B is the operator's literal phrasing
and would be right if the envelope is meant to diverge from the collider — if it is, say so,
because that is a different machine.
Q3 — Which tiling? D18 and D19 narrow this hard. Penrose cannot express panel size
(identical cell count at every radius) and R2 requires size to come from the item. TriHex is
inert (zero adjacency). Voronoi is 15 s at r10 and 1 s at r5. That leaves Hex as the
only kind that is both correct and sized. Recommended: Hex, and treat D18 as a real defect
to fix later if Penrose's look is wanted — Penrose is the better-looking tiling and losing it
is a genuine cost, but it cannot ship as the interceptor's tiling until it respects
panelRadius.
Q4 — Which fracture pattern per damage type, and where authored? Six of each, no natural
ordering. Proposed: Kinetic→Radial, Optical→Lance, Electric→Star, Thermal→Crystal,
Corrosive→Fizzle, Ionizing→Spiral. A: a serialized FracturePattern[] on the interceptor
indexed by (int)DamageType. B: a switch in code. C: item data. Recommended: A —
presentation choice belongs with the presentation and retunes without a compile. C is wrong
today: the absorb capability owns no data yet (CapabilityEvents.cs:17-20 parks it explicitly).
Q5 — How is the scene edited? A: one-shot editor script run in batchmode, deleted in
the same commit. B: hand-edited YAML. C: the operator clicks it together.
Ruled C (operator, 2026-09-18): "screw it, I'll do any editor shenanigans we need for
now." Cut 4 therefore ships no editor script and no YAML edit: it ships an exact rig recipe
for the operator (object names, parents, components, transform values, which asset goes in
which field) plus a batchmode check that reads the saved scene back and refuses a rig that is
wrong. Agent-driven editor commands through Brokkr are parked
(F:\Projects\Brokkr\docsgent-access-cut.md).
Superseded reasoning: Recommended: A. B is the failure mode that produced 1428ada3. If the operator would
rather keep the script as a menu item, say so — that makes it a standing second authority over
the rig and should be deliberate, not leftover.
Q6 — Per-pass draw toggles? drawFill/drawOutline/drawConduits die with the
single-call draw (§2.1.5). A: gone. B: back as shader uniforms zeroing each pass's alpha.
Recommended: A, until someone wants them. No consumer today.
Q7 — Buy NUnit coverage with an asmdef? Tests.asmdef references only
UnityEngine.TestRunner, UnityEditor.TestRunner and Aetheria.Shared.Unity, with
autoReferenced: false, so it cannot see Assembly-CSharp. A: no asmdef; pin the rules
with batchmode probes. B: add Aetheria.ShieldField.asmdef. Recommended: A, but
weaker than last pass: ShieldEnvelope (Cut 2) is pure testable math and is exactly the kind
of thing an asmdef would buy real coverage for. If the operator wants B, scope it to
ShieldEnvelope alone, not to the six sources — the envelope is the piece whose arithmetic
has a wrong answer that looks plausible (sphere vs ellipsoid normal), and it is three lines of
reference edge rather than a compile boundary across 5.4k lines.
9. Superseded: the flat-panel design
Kept because it explains why several defects are recorded the way they are, and because the shape itself is still wanted for a different item (Cut 4's note).
The committed pass at 1fdf8151 designed one permanent flat disc parented near the hull:
a ShieldPanel living in FieldShieldTest from scene load, materialising once at sim time
zero, with a ShieldPanelPresenter converting each AbsorbEvent into one Hit and doing
nothing else. It had no pool, no lifecycle, no envelope, and no pose problem — the panel's
transform was authored in the scene and never moved.
R2 retired it. What changed and why it matters:
- No pool → a pool. Panels are per-interception, so
Prototypeenters 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.
_PanelSpawnTimegoes 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.