# 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.