docs(phys): design spec — per-part BSP collision for server-spawned entities

Captures the brainstorm session 2026-05-24 evening after A6.P4 slice 1
(b49ed90) shipped without closing #99. Investigation surfaced the actual
root cause: doors register as a single 14cm × 20cm bounding-cylinder
approximation derived from Setup.Radius/Height fallback. Their real
collision-bearing geometry lives in per-part GfxObj BSPs (3 parts for
Setup 0x020019FF), including the threshold polygon spanning the doorway.

Retail-faithful design: every server-spawned entity registers N shadow
entries (one per CylSphere + one per Sphere + one per Part-with-BSP),
all sharing the same EntityId. UpdatePhysicsState propagates ETHEREAL
flips to all entries via the existing EntityId-iteration path. Unifies
the live-entity and landblock-static registration code paths under one
ShadowShapeBuilder.

Retail anchor: CObjCell::find_obj_collisions → CPhysicsObj::FindObjCollisions
→ CPartArray::FindObjCollisions → CPhysicsPart::find_obj_collisions →
CGfxObj::find_obj_collisions. One PhysicsObj per entity, parts iterated
internally for collision (acclient_2013_pseudo_c.txt:276776-275055).

Five-commit migration sequence; tests at three layers (builder unit tests,
registry behavior tests, live-capture regression pin). Approach A approved
by user 2026-05-24.

Spec stands on its own as M1.5 work; not formally assigned a phase letter
per CLAUDE.md's "don't invent phase numbers on the fly" rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 14:21:07 +02:00
parent b49ed904c3
commit d71ceaba9c

View file

@ -0,0 +1,553 @@
# Per-part BSP collision for server-spawned entities — design
**Status:** Drafted 2026-05-24 evening. Ready to start once the user approves the
spec.
**Milestone:** M1.5 — "Indoor world feels right" (active).
**Predecessor work:**
- A6.P4 slice 1 (commit `b49ed90`) — dropped the misleading `< 0x0100u` filter
in `ShadowObjectRegistry.GetNearbyObjects` and renamed `indoorCellIds`
`portalReachableCells`. Real cleanup; did not close #99 on its own.
- L.2g slice 1+1b+1c (2026-05-12/13) — wired `SetState (0xF74B)``UpdatePhysicsState`
`CollisionExemption.ShouldSkip` for ETHEREAL flip. State propagation works;
only the collision-shape registration is wrong.
**Closes:** #99 (run-through doors) — the part of it caused by the door registering
as a single 14 cm × 20 cm bounding-cylinder approximation that doesn't fill the
doorway gap.
**Out of scope (file follow-ups if surfaced):** A6.P4 slices 2-3 (BuildShadowCellSet,
b3ce505 stopgap retirement); issue #100 (transparent ground); reverse-portal-map.
---
## 1. Problem
Server-spawned entities (doors, chests, signs, NPCs, items) all flow through
`RegisterLiveEntityCollision` in `src/AcDream.App/Rendering/GameWindow.cs:3076`.
The function inspects `setup.CylSpheres`, `setup.Spheres`, and `setup.Radius`
and registers ONE `ShadowCollisionType.Cylinder` entry using whichever of the
three is present. Doors (Setup `0x020019FF`) have zero CylSpheres + one tiny
Sphere; the fallback picks `setup.Radius = 0.141 m` × `setup.Height = 0.200 m`.
That's a soda-can-sized cylinder at the doorway floor, easily walked around in
a 2 m doorway.
The L.2g 2026-05-12 design assumed Cylinder collision was retail's door
blocker. The trace it was based on showed the cylinder firing, but only when
the player approached dead-center. Off-center walks bypass it. The L.2g
visual verification was deferred and the assumption was never validated.
Retail's actual mechanism: the door's three `Setup.Parts` (GfxObjs
`0x010044B5`, `0x010044B6`, `0x010044B6`) each carry their own PhysicsBSP,
including the threshold polygon that spans the doorway. Retail's
`CPhysicsObj::FindObjCollisions` calls into `CPartArray::FindObjCollisions`
which iterates parts and dispatches to `CPhysicsPart::find_obj_collisions`
`CGfxObj::find_obj_collisions` (per-part BSP test).
acdream has no equivalent path for live entities. Static landblock-baked
objects (`GameWindow.cs:5907+`) DO register per-part BSP shadows correctly, but
under a separate code path with different ID schemes, so doors don't benefit.
---
## 2. Goal
Register every server-spawned entity's collision shapes the way retail
`CPhysicsObj` does: one logical entity, multiple per-part collision
contributions (CylSpheres + Spheres + per-Part BSPs), all sharing the entity's
PhysicsState so `ETHEREAL` flips propagate everywhere. Unify the live-entity
and landblock-static registration paths around one shape-builder so the
divergence that caused this bug can't recur for other entity classes.
**Acceptance criteria:**
- Walking dead-center at a closed Holtburg cottage door: blocked.
- Walking ~50 cm off-center at the same door: still blocked (the threshold
polygon spans the gap).
- Clicking the door → swing animation → walking through: passes through.
- 30 s auto-close → re-blocked from both sides.
- `LiveCompare_FirstCap_FixClosesCottageFloorCap` (issue #98 regression pin)
still passes.
- 11/11 `CellarUpTrajectoryReplayTests` still pass.
- All 22 existing `ShadowObjectRegistryTests` still pass via the single-shape
compat path.
- New `ShadowShapeBuilderTests` (6+ cases) green.
- New `ShadowObjectRegistryMultiPartTests` (7+ cases) green.
- New `LiveCompare_DoorBlocksWhenClosed` live-capture regression pin: green
post-fix, documents-the-bug pre-fix.
---
## 3. Retail anchor
```
CObjCell::find_obj_collisions (acclient_2013_pseudo_c.txt:308916)
iterates this->shadow_object_list
→ CPhysicsObj::FindObjCollisions (276776)
→ CPartArray::FindObjCollisions (276961 → 286236)
→ CPhysicsPart::find_obj_collisions (286250 → 275045)
→ CGfxObj::find_obj_collisions (275055)
per-part BSP polygon test in part-local frame
```
One `CPhysicsObj` per entity. Parts iterated internally. Each part tests its
GfxObj BSP. State (`ETHEREAL_PS = 0x4`, etc.) lives on the PhysicsObj — one
state check at the top of `FindObjCollisions`, applies to all parts uniformly.
---
## 4. Design (Approach A — multi-entry per logical entity, same EntityId)
### 4.1 Inversion summary
**Today:**
```
RegisterLiveEntityCollision
pick ONE shape via the CylSphere → Sphere → Setup.Radius cascade
→ ShadowObjectRegistry.Register(entityId, shape) → 1 ShadowEntry
FindObjCollisions iterates that 1 entry. Cylinder math runs against the fallback.
```
**After A6.P4 door fix:**
```
RegisterLiveEntityCollision
→ shapes = ShadowShapeBuilder.FromSetup(setup, entScale, cache)
[returns N shapes: every CylSphere + every Sphere + every Part-with-BSP]
→ ShadowObjectRegistry.RegisterMultiPart(entityId, shapes, ...)
→ N ShadowEntry rows added to _cells, all sharing EntityId, each with its
LocalPosition/LocalRotation captured for UpdatePosition replay
FindObjCollisions iterates each row independently. Per-part BSP test for BSP rows;
Cylinder math for Cylinder rows. ETHEREAL flip propagates via UpdatePhysicsState
walking _entityToCells[entityId] and updating every entry with matching EntityId.
```
### 4.2 Data model
New record:
```csharp
namespace AcDream.Core.Physics;
/// <summary>
/// One collision-bearing shape attached to a logical PhysicsObj.
/// Doors emit 3-4 shapes (CylSpheres + Spheres + Part-BSPs); creatures
/// may emit a few; simple items may emit one or zero.
/// Positions/rotations are LOCAL to the entity origin so UpdatePosition
/// can re-transform them when the entity moves.
/// </summary>
public readonly record struct ShadowShape(
uint GfxObjId,
Vector3 LocalPosition,
Quaternion LocalRotation,
float Scale,
ShadowCollisionType CollisionType,
float Radius,
float CylHeight);
```
`ShadowEntry` gains two fields:
```csharp
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position, // world-space
Quaternion Rotation, // world-space
float Radius,
ShadowCollisionType CollisionType,
float CylHeight,
float Scale,
uint State,
EntityCollisionFlags Flags,
// NEW — needed by UpdatePosition to recompute Position/Rotation
Vector3 LocalPosition = default,
Quaternion LocalRotation = default);
```
`ShadowObjectRegistry` gains one private map:
```csharp
private readonly Dictionary<uint, IReadOnlyList<ShadowShape>> _entityShapes = new();
```
Stores the original shape list per EntityId so UpdatePosition can rebuild
ShadowEntries when the entity moves. Cleared by Deregister.
`_cells` and `_entityToCells` are unchanged in structure — `_entityToCells[entityId]`
just holds more cells when an entity's parts span multiple landcells.
### 4.3 Components & files
| File | Action | Approx LOC |
|---|---|---|
| `src/AcDream.Core/Physics/ShadowShape.cs` | **NEW** — record (Section 4.2) | ~25 |
| `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | **NEW**`FromSetup(Setup, float entScale, PhysicsDataCache cache) → IReadOnlyList<ShadowShape>`. Walks CylSpheres → Spheres → Parts. For each Part, look up its GfxObj; only emit a BSP shape if `cache.GetGfxObj(partId)?.BSP?.Root` is non-null. Pure function. | ~80 |
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | Add `RegisterMultiPart`. `Register` (single-shape API) becomes a 1-element wrapper calling `RegisterMultiPart`. Add `_entityShapes` map. Extend `UpdatePosition` to re-transform shapes from `_entityShapes`. `UpdatePhysicsState` and `Deregister` unchanged in behavior (already iterate by EntityId; Deregister now clears `_entityShapes[entityId]` too). | net +~80 |
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (ShadowEntry struct) | Add `LocalPosition` + `LocalRotation` (default-value-compat). | ~6 |
| `src/AcDream.App/Rendering/GameWindow.cs:3076` (`RegisterLiveEntityCollision`) | Replace body: build shapes via `ShadowShapeBuilder.FromSetup`, call `RegisterMultiPart`. Remove the CylSphere/Sphere/Radius cascade — now in the builder. | -50 / +25 |
| `src/AcDream.App/Rendering/GameWindow.cs:5907+` (static landblock per-part loop) | Replace with a single `RegisterMultiPart` call using `ShadowShapeBuilder.FromSetup`. Unifies live + static. | -300 / +30 |
| `tests/AcDream.Core.Tests/Fixtures/door/0x020019FF.setup.json` | **NEW** fixture — dumped via extended `ACDREAM_DUMP_SETUPS=0x020019FF`. | n/a |
| `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs` | **NEW** — 6+ tests (see §6). | ~100 |
| `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs` | **NEW** — 7+ tests (see §6). | ~150 |
| `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` | **NEW** test `LiveCompare_DoorBlocksWhenClosed` from a live capture. | ~80 |
| Existing `ShadowObjectRegistryTests` (22 tests) | Audit. Verify all pass unchanged via the compat path. | 0 |
Net: ~500 new LOC, ~350 deleted from `GameWindow.cs`. Ownership shifts to
`AcDream.Core.Physics` per code-structure rule #1 in CLAUDE.md.
### 4.4 Data flow (four critical scenarios)
**4.4.a Door spawn**
```
CreateObject(0xF745) guid=0x7A9B4015 setup=0x020019FF pos=(132.6,17.1,94.1)
→ GameWindow.OnEntitySpawn
→ RegisterLiveEntityCollision
→ shapes = ShadowShapeBuilder.FromSetup(setup, 1.0, _physicsDataCache)
returns [
ShadowShape(0x020019FF, (0,0,0.018), Identity, 1.0, Sphere/Cyl-approx, r=0.100, h=0.200),
ShadowShape(0x010044B5, frame[0].Pos, frame[0].Rot, 1.0, BSP, r=bspR0, h=0),
ShadowShape(0x010044B6, frame[1].Pos, frame[1].Rot, 1.0, BSP, r=bspR1, h=0),
ShadowShape(0x010044B6, frame[2].Pos, frame[2].Rot, 1.0, BSP, r=bspR2, h=0)
]
(CylSpheres list empty → no cylinder shapes; if any existed they'd be emitted first.)
→ RegisterMultiPart(entityId=0x000F4244, worldPos, worldRot, shapes, state=0x10008, ...)
→ 4 ShadowEntry rows in _cells, all EntityId=0x000F4244
→ _entityShapes[0x000F4244] = shapes
→ _entityToCells[0x000F4244] = [all cells touched by the 4 entries, deduped]
```
**4.4.b Collision check (player walks into closed door)**
```
PhysicsEngine.ResolveWithTransition
→ Transition.FindObjCollisions
→ CellTransit.FindCellSet → portalReachableCells
→ ShadowObjects.GetNearbyObjects → returns 4 entries for door
→ for each entry:
- CollisionExemption.ShouldSkip(state=0x10008, ...) → not ethereal → proceed
- if entry.CollisionType == BSP:
BSPQuery.FindCollisions(gfxObjId BSP, sphere transformed to entry's local space)
→ threshold polygon hit → Collided/Adjusted/Slid → CollideObjectGuids += entityId
else Cylinder:
CylinderCollision(entry, sp) (existing math)
```
Threshold polygon lives in one of the door's Parts' GfxObj BSP. BSPQuery finds
it. Player blocks. No matter where the player approaches from (any direction,
any offset within the doorway), the threshold polygon spans the doorway and
catches the sphere.
**4.4.c Door Use → server flips Ethereal**
```
ACE Door.ActOnUse(player)
→ GameMessageSetState(0xF74B): guid=0x7A9B4015 state=0x1000C (ETHEREAL set)
→ GameWindow.OnLiveStateUpdated
→ ShadowObjects.UpdatePhysicsState(0x000F4244, 0x1000C)
→ walk _entityToCells[0x000F4244] (4 cells)
→ in each cell, rewrite each entry with EntityId==0x000F4244 to State=0x1000C
→ next FindObjCollisions: CollisionExemption.ShouldSkip sees ETHEREAL → skips every entry → no collision
→ player walks through.
```
Auto-close after 30 s reverses the flip: state=0x10008 → all parts re-block.
**4.4.d Position update** (mostly NPCs walking, not doors)
```
UpdatePosition(entityId, newWorldPos, newWorldRot, ...)
→ look up _entityShapes[entityId]
→ for each shape, recompute world position:
partWorldPos = newWorldPos + Vector3.Transform(shape.LocalPosition * shape.Scale, newWorldRot)
partWorldRot = newWorldRot * shape.LocalRotation
→ effectively: Deregister + RegisterMultiPart with cached shape list
```
For doors specifically, this path rarely triggers — doors animate (the leaves
swing) but their PhysicsObj origin stays put. The Cylinder/Sphere/Parts move
with the door's entity transform, which is constant. The leaf swing is a
visual animation only; the threshold polygon is in the static frame.
### 4.5 ShadowShapeBuilder details
Pseudocode:
```csharp
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup, float entScale, PhysicsDataCache cache)
{
var result = new List<ShadowShape>();
AnimationFrame? frame = ResolvePlacementFrame(setup);
// CylSpheres
foreach (var cyl in setup.CylSpheres)
{
if (cyl.Radius <= 0f) continue;
float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f;
result.Add(new ShadowShape(
GfxObjId: 0, // unused for Cylinder
LocalPosition: cyl.Origin * entScale,
LocalRotation: Quaternion.Identity,
Scale: entScale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: cyl.Radius * entScale,
CylHeight: baseHeight * entScale));
}
// Spheres (only when CylSpheres absent; retail uses one or the other, not both,
// for the "body" cylinder. Spheres-without-CylSpheres is the door pattern.)
if (setup.CylSpheres.Count == 0)
{
foreach (var sph in setup.Spheres)
{
if (sph.Radius <= 0f) continue;
result.Add(new ShadowShape(
GfxObjId: 0,
LocalPosition: sph.Origin * entScale,
LocalRotation: Quaternion.Identity,
Scale: entScale,
CollisionType: ShadowCollisionType.Cylinder, // sphere ≈ short cylinder
Radius: sph.Radius * entScale,
CylHeight: sph.Radius * 2f * entScale));
}
}
// Parts — one BSP shape per part that has a non-null PhysicsBSP.
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = (uint)setup.Parts[i];
var phys = cache.GetGfxObj(gfxId);
if (phys?.BSP?.Root is null) continue;
Frame partFrame = frame is not null && i < frame.Frames.Count
? frame.Frames[i]
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
float radius = (phys.BoundingSphere?.Radius ?? 1f) * entScale;
result.Add(new ShadowShape(
GfxObjId: gfxId,
LocalPosition: partFrame.Origin * entScale,
LocalRotation: partFrame.Orientation,
Scale: entScale,
CollisionType: ShadowCollisionType.BSP,
Radius: radius,
CylHeight: 0f));
}
return result;
}
```
Placement frame resolution follows the same priority as `SetupMesh.Flatten`:
`PlacementFrames[Resting]``[Default]` → first available.
The `Sphere ≈ short cylinder` substitution matches the existing pattern in
`GameWindow.cs:6020` (landblock-static path) — both paths use the same
approximation, so behavior is consistent.
### 4.6 RegisterMultiPart details
Pseudocode:
```csharp
public void RegisterMultiPart(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
IReadOnlyList<ShadowShape> shapes,
uint state,
EntityCollisionFlags flags,
float worldOffsetX, float worldOffsetY, uint landblockId,
uint cellScope = 0u)
{
Deregister(entityId);
if (shapes.Count == 0) return;
_entityShapes[entityId] = shapes;
var allCells = new List<uint>();
var seenCells = new HashSet<uint>();
foreach (var shape in shapes)
{
// Compose world transform: world = entityRot · localPos + entityPos
var rotatedLocal = Vector3.Transform(shape.LocalPosition, entityWorldRot);
var partWorldPos = entityWorldPos + rotatedLocal;
var partWorldRot = entityWorldRot * shape.LocalRotation;
var entry = new ShadowEntry(
EntityId: entityId,
GfxObjId: shape.GfxObjId,
Position: partWorldPos,
Rotation: partWorldRot,
Radius: shape.Radius,
CollisionType: shape.CollisionType,
CylHeight: shape.CylHeight,
Scale: shape.Scale,
State: state,
Flags: flags,
LocalPosition: shape.LocalPosition,
LocalRotation: shape.LocalRotation);
AddEntryToCells(entry, partWorldPos, shape.Radius, worldOffsetX, worldOffsetY,
landblockId, cellScope, allCells, seenCells);
}
_entityToCells[entityId] = allCells;
}
```
`AddEntryToCells` is extracted from the current single-shape `Register` body —
same cell-occupancy logic per entry. Shared between `Register` (single-shape
compat) and `RegisterMultiPart`.
### 4.7 The single-shape compat path
```csharp
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, ...)
{
// Wrap as single-shape ShadowShape list and forward to RegisterMultiPart.
var shape = new ShadowShape(
GfxObjId: gfxObjId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: collisionType,
Radius: radius,
CylHeight: cylHeight);
RegisterMultiPart(entityId, worldPos, rotation, new[] { shape },
state, flags, worldOffsetX, worldOffsetY, landblockId, cellScope);
}
```
All 22 existing `ShadowObjectRegistryTests` use this signature. They continue
working unchanged.
---
## 5. Risks & mitigation
**5.1 BSP collision against the door's parts produces wrong results because part transforms aren't applied.**
Each `setup.Parts[i]` is in local-to-part space. The Setup's
`PlacementFrames[Default][i]` is the part's local-to-setup transform. The BSP
polygons inside the part's GfxObj are in part-local space.
Mitigation: `ShadowShape.LocalPosition` + `LocalRotation` store the
part's local-to-entity transform. `RegisterMultiPart` composes the entity's
world transform with the local transform to produce `entry.Position` /
`entry.Rotation`. The existing BSP collision branch
(`TransitionTypes.cs:2250+`) uses `obj.Position` + `obj.Rotation` to transform
spheres into BSP-local space. Same path the static-landblock per-part BSP
already uses successfully. We adopt that working pattern unchanged.
Retail anchor: `CPhysicsPart::find_obj_collisions:275055` calls
`CGfxObj::find_obj_collisions(gfxobj, arg2, scale)` — the part stores its
own transform; the BSP test runs in part-local frame. Matches our model.
**5.2 Performance — entities with many parts produce many shadow rows.**
The landblock-static path already does this with no observed perf cost.
Broadphase culls each row by position+radius. For a 4-part door at 132 m, a
player at 127 m broad-phase-rejects 3 of 4 parts.
Mitigation: none needed. Retail incurs the same per-part cost.
**5.3 State propagation completeness — does `UpdatePhysicsState` reach all parts?**
After `RegisterMultiPart`, all part entries share `EntityId`. The existing
`UpdatePhysicsState` iterates `_entityToCells[entityId]` and updates every
entry where `EntityId == entityId`. One `SetState` call propagates to every
part. The conformance test
`UpdatePhysicsState_PropagatesToAllParts` pins this.
Mitigation: covered by the new test.
---
## 6. Testing
### 6.1 `ShadowShapeBuilderTests` — pure unit tests
Fixture: `tests/AcDream.Core.Tests/Fixtures/door/0x020019FF.setup.json` (real dat
dump). Builder is a pure function; tests are deterministic.
- `FromSetup_DoorSetup_ProducesFourShapes` — 0 cylinders + 1 sphere + 3 BSP
- `FromSetup_DoorSetup_SphereAtLocalOffset` — sphere position == (0, 0, 0.018)
- `FromSetup_DoorSetup_PartTransforms_MatchPlacementFrames`
- `FromSetup_PartWithoutBsp_SkipsBspShape` — fixture with a UI-only part
- `FromSetup_CreatureSetup_OnlyCylinders` — fixture: creature setup with CylSpheres + parts whose GfxObjs have no BSP
- `FromSetup_ScaleFactor_AppliedToRadii``entScale=2.0` doubles every radius
### 6.2 `ShadowObjectRegistryMultiPartTests` — registry behavior
- `RegisterMultiPart_FourShapes_AllInRegistry`
- `RegisterMultiPart_AllShareEntityId`
- `Deregister_RemovesAllParts` (and clears `_entityShapes[entityId]`)
- `UpdatePhysicsState_PropagatesToAllParts`
- `UpdatePosition_MovesAllPartsWithEntity`
- `RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed`
- `Register_SingleShapeCompat_Unchanged` — existing API still works
### 6.3 Live capture regression test
Re-run launch with `ACDREAM_CAPTURE_RESOLVE=path.jsonl`. Walk into a closed
door. Capture the tick where the door blocks. Add JSONL record + door's setup
fixture. Write `LiveCompare_DoorBlocksWhenClosed` in
`CellarUpTrajectoryReplayTests.cs`:
- Pre-fix: documents-the-bug (passes when harness lacks the threshold poly,
i.e. before this design lands)
- Post-fix: asserts `result` hit with `obj` attributed to a door entity
Mirror of the issue-#98 `LiveCompare_FirstCap_FixClosesCottageFloorCap` pattern.
### 6.4 Visual verification (user)
1. Cottage cellar climb — still works (no #98 regression)
2. Cottage doorway from outside (center) — blocks
3. Cottage doorway from outside (~50 cm off-center) — blocks
4. Use door → swing animation → walk through — passes
5. Wait 30 s → close animation → re-blocks both sides
6. Indoor furniture (chair, fireplace) — still blocks
7. Outdoor cottage exterior — still blocks
### 6.5 Existing tests audit
- 22 `ShadowObjectRegistryTests` — must pass unchanged via the compat path
- 11/11 `CellarUpTrajectoryReplayTests` — must pass unchanged
---
## 7. Migration sequence
| Commit | Title | Verification gate |
|---|---|---|
| 1 | `feat(phys): ShadowShape + ShadowShapeBuilder` (no callers yet) | `ShadowShapeBuilderTests` green |
| 2 | `feat(phys): ShadowObjectRegistry.RegisterMultiPart + ShadowEntry local-offset fields` | `ShadowObjectRegistryMultiPartTests` green; existing 22 tests still green |
| 3 | `refactor(phys): RegisterLiveEntityCollision uses ShadowShapeBuilder + RegisterMultiPart` | `LiveCompare_DoorBlocksWhenClosed` green; 11/11 cellar tests green |
| 4 | `refactor(phys): landblock-baked statics use ShadowShapeBuilder + RegisterMultiPart` | Same tests green; visual verifies no static-collision regression |
| 5 | `docs: door collision per-part BSP — ship` | None |
Each commit independently buildable + test-green per CLAUDE.md. Visual
verification gates after commit 3 (door fix) and commit 4 (regression check on
statics).
---
## 8. Out of scope (kept-near reminders)
- **A6.P4 slices 2-3** (BuildShadowCellSet + b3ce505 stopgap retirement) —
separate work. After this door fix lands, the b3ce505 gate becomes a pure
performance optimization (skipping a radial sweep that wouldn't find
anything new), so slice 3's removal is still clean and correct.
- **Issue #100** (transparent ground around houses) — separate rendering
phase, untouched.
- **Reverse-portal-map** (A6.P4 spec §3.2.a) — only needed if the door fix
doesn't fully close the inside-approach case. The portal-reachable cell
set already covers it for spheres at the doorway threshold.
- **CPartArray-level optimization** — retail's PartArray has a bounding
sphere covering all parts, used for an early-out. Our per-part broadphase
pruning is equivalent. If perf ever becomes a concern, file a separate
optimization phase.
---
## 9. Open questions
None at design time. The retail-anchor + the static-landblock pattern + the
existing UpdatePhysicsState/Deregister behavior give a complete design. Open
questions, if any, will surface during implementation and get filed as
follow-ups.