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.