From d71ceaba9c247c32cd344dc2f3425d3d6093820c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 May 2026 14:21:07 +0200 Subject: [PATCH] =?UTF-8?q?docs(phys):=20design=20spec=20=E2=80=94=20per-p?= =?UTF-8?q?art=20BSP=20collision=20for=20server-spawned=20entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...5-24-door-collision-per-part-bsp-design.md | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md diff --git a/docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md b/docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md new file mode 100644 index 0000000..e3b2f92 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-door-collision-per-part-bsp-design.md @@ -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; + +/// +/// 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. +/// +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> _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`. 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 FromSetup( + Setup setup, float entScale, PhysicsDataCache cache) +{ + var result = new List(); + 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 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(); + var seenCells = new HashSet(); + + 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.