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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:21:07 +02:00

24 KiB
Raw Blame History

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 indoorCellIdsportalReachableCells. Real cleanup; did not close #99 on its own.
  • L.2g slice 1+1b+1c (2026-05-12/13) — wired SetState (0xF74B)UpdatePhysicsStateCollisionExemption.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_collisionsCGfxObj::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:

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 NEWFromSetup(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 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_AppliedToRadiientScale=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.