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>
24 KiB
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< 0x0100ufilter inShadowObjectRegistry.GetNearbyObjectsand renamedindoorCellIds→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.ShouldSkipfor 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,b3ce505stopgap 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
CellarUpTrajectoryReplayTestsstill pass. - All 22 existing
ShadowObjectRegistryTestsstill pass via the single-shape compat path. - New
ShadowShapeBuilderTests(6+ cases) green. - New
ShadowObjectRegistryMultiPartTests(7+ cases) green. - New
LiveCompare_DoorBlocksWhenClosedlive-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:
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:
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:
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:
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:
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
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 BSPFromSetup_DoorSetup_SphereAtLocalOffset— sphere position == (0, 0, 0.018)FromSetup_DoorSetup_PartTransforms_MatchPlacementFramesFromSetup_PartWithoutBsp_SkipsBspShape— fixture with a UI-only partFromSetup_CreatureSetup_OnlyCylinders— fixture: creature setup with CylSpheres + parts whose GfxObjs have no BSPFromSetup_ScaleFactor_AppliedToRadii—entScale=2.0doubles every radius
6.2 ShadowObjectRegistryMultiPartTests — registry behavior
RegisterMultiPart_FourShapes_AllInRegistryRegisterMultiPart_AllShareEntityIdDeregister_RemovesAllParts(and clears_entityShapes[entityId])UpdatePhysicsState_PropagatesToAllPartsUpdatePosition_MovesAllPartsWithEntityRegisterMultiPart_PartsAcrossMultipleCells_AllCellsListedRegister_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
resulthit withobjattributed to a door entity
Mirror of the issue-#98 LiveCompare_FirstCap_FixClosesCottageFloorCap pattern.
6.4 Visual verification (user)
- Cottage cellar climb — still works (no #98 regression)
- Cottage doorway from outside (center) — blocks
- Cottage doorway from outside (~50 cm off-center) — blocks
- Use door → swing animation → walk through — passes
- Wait 30 s → close animation → re-blocks both sides
- Indoor furniture (chair, fireplace) — still blocks
- 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 +
b3ce505stopgap retirement) — separate work. After this door fix lands, theb3ce505gate 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.