acdream/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Erik 48f0b26f62 docs(spec): Indoor portal-based cell tracking design
Brainstormed spec for the follow-up to Cluster A: port retail's portal-graph
cell traversal to replace Phase D's AABB containment shortcut. Closes
ISSUES.md #87 and the remaining wall-collision parts of #84 + #85 — indoor
walking with walls that block from inside, walking through doors that
updates CellId.

Scope: all three transition types (indoor↔indoor, indoor↔outdoor,
outdoor→indoor). AABB containment deleted entirely; portal traversal is the
only path.

Key data references: docs/research/acclient_indoor_transitions_pseudocode.md
(2026-04-13) has the entire algorithm already documented from ACE source
cross-referenced against the retail header. BSPQuery.PointInsideCellBsp is
already wired (just unused).

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

26 KiB
Raw Blame History

Indoor Portal-Based Cell Tracking — Design

Status: Brainstormed 2026-05-19. Awaiting user spec review before plan. Scope: Port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 and #85 (indoor walking — walls don't block, walking through doors doesn't update CellId). Predecessor: Cluster A (docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md) shipped 2026-05-19. Phase D's AABB containment was a deliberate shortcut that the capture log proved insufficient for normal indoor walking. Retail pseudocode reference: docs/research/acclient_indoor_transitions_pseudocode.md (2026-04-13) — the entire algorithm is already documented from ACE source cross-referenced against the retail header. This spec is the porting plan, not a re-derivation.


1. What we know

The 2026-04-13 research doc enumerates:

  • CObjCell::find_cell_list — the top-level driver, called every movement tick. Builds the list of cells a sphere overlaps + identifies the new "current cell" via point-in-cell.
  • CEnvCell::find_transit_cells (sphere variant) — walks portal neighbors of an indoor cell. Adds neighbor cells whose sphere_intersects_cell returns Inside or Crossing.
  • CEnvCell::check_building_transit — the outdoor→indoor entry path, invoked from BuildingObj::find_building_transit_cells.
  • CLandCell::add_all_outside_cells — outdoor neighbor expansion on the 24m landcell grid.
  • CCellStruct::point_in_cell → tail-calls BSPTREE::point_inside_cell_bsp(cell_bsp, localPoint). The cell_bsp is a third BSP per cell, separate from physics_bsp and drawing_bsp.

acdream already has:

  • BSPQuery.PointInsideCellBsp(node, point) at src/AcDream.Core/Physics/BSPQuery.cs:940 — the canonical retail port of point_inside_cell_bsp. Currently wired but unused.
  • LoadedCell.Portals (in AcDream.App.Rendering) — populated from envCell.CellPortals for the visibility renderer. Used for portal-BFS visibility, not collision.
  • PhysicsDataCache.CacheCellStruct caches CellStruct.PhysicsBSP (collision BSP) + PhysicsPolygons + VertexArray. Does NOT currently cache CellStruct.CellBSP or portal data.

Capture evidence (launch-cluster-a-cache-diag3.log, launch-cluster-a-verify.log):

  • Holtburg interior cells DO have full physics geometry (e.g. 0xA9B40143 has 14 polys all resolved, AABB (-11.60, -1.60, 0.00) → (-6.20, 7.60, 2.80)).
  • Phase D's AABB containment fires for ~6 frames per session (mid-jump apex). The threshold/doorway cells with thin Z AABB (e.g. 0xA9B40146 with AABB Z [-0.20, 0.00]) never capture a standing player.
  • Result: indoor cell-BSP collision branch fires intermittently; walls don't consistently block.

2. Goal

Port retail's portal-graph cell traversal so:

  1. The player's CellId tracks indoor cells correctly when walking inside a building.
  2. Walking through a doorway (portal) promotes/demotes CellId correctly.
  3. Walking into a building from outside (through a BuildingObj portal) promotes CellId to the right interior cell.
  4. The indoor cell-BSP collision branch fires every frame the player is in an indoor cell, so walls block consistently.

Out of scope:

  • Visibility-side portal traversal (CellVisibility / LoadedCell.Portals) — kept as-is. This phase is collision-side only.
  • Two-sphere parts/AABB variant of find_transit_cells (used for creatures and large objects) — port only the player's single-sphere case for now.
  • VisibleCells cleanup filter — the optional last step of find_cell_list that strips invisible cells from the candidate set. Skip; the BSP-based point-in-cell already picks one winner.
  • Multi-step sub-tick portal crossings within a single movement step — retail handles fast movement that crosses multiple portals; we'll port the basic single-crossing case and revisit if regressions surface.

3. Architecture

                          Movement tick (per substep)
                                │
                                ▼
              PhysicsEngine.ResolveCellId(worldPos, currentCellId)
                                │
                                ▼
          ╔═══════════════════════════════════════════════╗
          ║ CellTransit.FindCellList                      ║
          ║                                               ║
          ║  current is indoor (low >= 0x0100)?           ║
          ║    yes ─► seed cellArray with current EnvCell ║
          ║    no  ─► add_all_outside_cells (LandCell)    ║
          ║           + check_building_transit hits       ║
          ║                                               ║
          ║  for each cell in cellArray (BFS-like):       ║
          ║    cell.find_transit_cells(sphere) ──► add    ║
          ║      neighbours via portal-graph walk         ║
          ║                                               ║
          ║  for each cell in cellArray:                  ║
          ║    if PointInsideCellBsp(cell.CellBSP, lpos): ║
          ║      ─► newCurrentCell = cell, break          ║
          ╚═══════════════════════════════════════════════╝
                                │
                                ▼
              sp.CheckCellId = newCurrentCell.Id (full prefix)
                                │
                                ▼
              [indoor-bsp] probe fires correctly for indoor cells
              Cell-BSP collision branch in FindEnvCollisions runs

The hot path runs once per FindEnvCollisions call. Portal-graph traversal walks the local neighborhood (current cell + 1-2 hops). Typical work per tick: ~5-10 BSP point tests, each O(BSP depth) ≈ O(log N). Cheaper than the current AABB scan over all loaded cells.


4. Components

4.1 Data types (extend / add)

CellPhysics (extended — same record/class as today):

Field Status Source
BSP existing cellStruct.PhysicsBSP (collision)
PhysicsPolygons existing cellStruct.PhysicsPolygons
Vertices existing cellStruct.VertexArray
WorldTransform existing passed in from GameWindow
InverseWorldTransform existing computed
Resolved existing from ResolvePolygons
LocalAabbMin / LocalAabbMax delete Phase D AABB shortcut
CellBSP add cellStruct.CellBSP (third BSP for point-in-cell)
Portals add IReadOnlyList<PortalInfo> from envCell.CellPortals
VisibleCellIds add (optional, deferred) envCell.VisibleCells keys — for future cleanup filter; populated but unused in this phase
PortalPolygons add cellStruct.Polygons resolved by id (separate from PhysicsPolygons; portals reference visible polys)

PortalInfo (new readonly struct in AcDream.Core.Physics):

public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)
{
    /// <summary>Bit 2 of Flags. See research doc §"PortalSide flag semantics".</summary>
    public bool PortalSide => (Flags & 2) == 0;
}

BuildingPhysics (new sealed class in AcDream.Core.Physics):

public sealed class BuildingPhysics
{
    public required Matrix4x4 WorldTransform;
    public required Matrix4x4 InverseWorldTransform;
    public required IReadOnlyList<BldPortalInfo> Portals;
}

public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch);

One BuildingPhysics per outdoor landcell that contains a building stab. Used for outdoor→indoor entry.

4.2 Caching (extend PhysicsDataCache)

CacheCellStruct(envCellId, cellStruct, worldTransform) — extended:

After the existing Resolved = ResolvePolygons(...) step, also populate the new fields:

  • CellBSP = cellStruct.CellBSP (verify field name during plan-writing; the DAT type may use CellBSP, CellBsp, or similar)
  • Portals = envCell.CellPortals.Select(cp => new PortalInfo(cp.OtherCellId, cp.PolygonId, cp.Flags)).ToList(). Decision: change CacheCellStruct's signature to CacheCellStruct(uint envCellId, EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform) so portal data and other EnvCell-side fields are available in a single atomic call. One call site (GameWindow.cs:5384); change is mechanical.
  • VisibleCellIds = new HashSet<uint>(envCell.VisibleCells.Keys) — populated but unused in this phase.
  • PortalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray) — same shape as Resolved but built from the visible polygon table (since portal PolygonId indexes Polygons, not PhysicsPolygons — confirmed in GameWindow.cs:5685).

CacheBuilding(landcellId, portals, buildingWorldTransform) — new:

Invoked from GameWindow.BuildInteriorEntitiesForStreaming for each landcell that contains a building stab. The DAT data shape (BldPortals from LandBlockInfo.Buildings) needs verification during plan-writing.

Deleted methods:

  • PhysicsDataCache.TryFindContainingCell — Phase D's AABB containment scan.
  • The AABB-compute block inside CacheCellStruct.

4.3 CellTransit (new static class)

New file: src/AcDream.Core/Physics/CellTransit.cs. Pure-static, owns three public functions:

public static class CellTransit
{
    /// <summary>
    /// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant).
    /// Returns the cell id whose CellBSP contains the sphere center, or the original
    /// fallback cell id if no cell matches.
    /// </summary>
    public static uint FindCellList(
        PhysicsDataCache cache,
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId,
        out CellSet candidateSet);

    /// <summary>
    /// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells
    /// (sphere variant). For each portal of `currentCell`, tests whether the sphere
    /// could overlap the neighbour cell and adds it to `candidateSet`.
    /// </summary>
    public static void FindTransitCellsSphere(
        PhysicsDataCache cache,
        CellPhysics currentCell,
        uint currentCellId,
        Vector3 worldSphereCenter,
        float sphereRadius,
        ref CellSet candidateSet);

    /// <summary>
    /// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells +
    /// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`,
    /// resolves the destination EnvCell and tests whether the sphere is inside it
    /// via PointInsideCellBsp.
    /// </summary>
    public static void CheckBuildingTransit(
        PhysicsDataCache cache,
        BuildingPhysics buildingPhysics,
        Vector3 worldSphereCenter,
        float sphereRadius,
        ref CellSet candidateSet);

    /// <summary>
    /// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells.
    /// Computes the player's 2D position within the 24×24m landcell and adds
    /// neighbour landcells whose boundary the sphere crosses.
    /// </summary>
    public static void AddAllOutsideCells(
        PhysicsDataCache cache,
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId,
        ref CellSet candidateSet);
}

CellSet is a small helper — either HashSet<uint> or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile.

4.4 PhysicsEngine.ResolveCellId (rename + rewrite)

Replaces PhysicsEngine.ResolveOutdoorCellId. New name + signature extended with a sphereRadius argument (needed by FindTransitCellsSphere for the sphere-vs-portal-plane test). Body becomes:

internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
    if (fallbackCellId == 0) return 0;
    if (DataCache is null) return fallbackCellId;

    uint newCellId = CellTransit.FindCellList(
        DataCache,
        worldPos,
        sphereRadius,
        currentCellId: fallbackCellId,
        out _);

    return newCellId != 0 ? newCellId : fallbackCellId;
}

The caller (Transition.FindEnvCollisions at TransitionTypes.cs:1181) has sp.GlobalSphere[0].Radius available and passes it through. The other two PhysicsEngine call sites (Resolve, ResolveWithTransition) need to plumb the sphere radius from their respective callers; the existing physics types carry it.

Three existing call sites of ResolveOutdoorCellId get renamed AND updated to pass the sphere radius:

  • PhysicsEngine.ResolveWithTransition (line ~729)
  • PhysicsEngine.Resolve (line ~287)
  • Transition.FindEnvCollisions (TransitionTypes.cs:1181)

4.5 Bootstrap on teleport

When the player teleports to a new cell (server-provided cell id from the network), the existing teleport path stores the cell id and triggers ResolveCellId on the next physics tick. Two cases:

  • Server-provided cell id is loaded in our cache → FindCellList starts from that cell, walks the portal graph, point-in-cell determines the actual current cell. Works correctly.
  • Server-provided cell id is NOT yet loadedFindCellList falls through to AddAllOutsideCells (treats as outdoor). The next tick after streaming loads the cell, the portal-graph walk picks it up.

Acceptance for teleport: player teleporting to an indoor cell (e.g. Holtburg cottage interior) gets the correct CellId on the first or second tick after spawn. Documented as a known edge case if the streaming takes more than one tick.


5. Data flow

Landblock load (one-time per landblock)

GameWindow.BuildInteriorEntitiesForStreaming(landblockId, lbInfo)
   │
   ▼
  For each EnvCell:
   envCell = _dats.Get<EnvCell>(envCellId)
   cellStruct = environment.Cells[envCell.CellStructure]
   cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump)
   _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform)
       │  populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform,
       │             InverseWorldTransform, Resolved, Portals, PortalPolygons,
       │             VisibleCellIds

  For each landcell containing a building (LandBlockInfo.Buildings):
   _physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform)
       │  populates: BldPortals list + buildingWorldTransform

Movement tick (per substep)

PhysicsEngine.ResolveWithTransition starts
   │
   ▼
  Transition.FindEnvCollisions:
   sp.CheckCellId = ... (current cell estimate)
   sphereRadius = sp.GlobalSphere[0].Radius
   newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId)
   if newCellId != sp.CheckCellId:
       sp.SetCheckPos(sp.CheckPos, newCellId)
   │
   ▼
  Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100
   ├── BSPQuery.FindCollisions(cellPhysics.BSP, ...)  ← walls collide here
   └── [indoor-bsp] probe emits a log line
   │
   ▼
  Outdoor terrain collision (unchanged)

6. Commit shape (preview)

  1. feat(physics): wire CellBSP + Portals + PortalPolygons into CellPhysics — extend CellPhysics shape; update CacheCellStruct signature to accept envCell (for portal data); deletes LocalAabbMin/Max fields and the AABB compute. Tests verify a synthetic EnvCell with portals + CellBSP populates the new fields correctly.
  2. feat(physics): port find_transit_cells sphere variant for indoor portals — new CellTransit.FindTransitCellsSphere. Tests use a synthetic two-cell portal pair to verify a sphere crossing the portal poly adds the neighbour cell.
  3. feat(physics): port BuildingPhysics + check_building_transit for outdoor→indoorCacheBuilding + CellTransit.CheckBuildingTransit. GameWindow wiring at landblock load. Tests verify a sphere overlapping a building portal triggers indoor-cell add.
  4. feat(physics): port add_all_outside_cells for landcell neighboursCellTransit.AddAllOutsideCells. Tests cover the 24×24m grid boundary cases.
  5. feat(physics): port find_cell_list driver, wire into ResolveCellId, delete AABB containment — top-level driver; rename ResolveOutdoorCellIdResolveCellId and update 3 call sites; delete PhysicsDataCache.TryFindContainingCell. Rewrites the 4 Phase D tests (ResolveOutdoorCellIdIndoorContainmentTests) to use the portal traversal mechanism.
  6. Capture session (user-driven) — walk the Holtburg cottage with ACDREAM_PROBE_INDOOR_BSP=1 + ACDREAM_PROBE_CELL=1 + ACDREAM_PROBE_CELL_CACHE=1. Verify all four acceptance criteria below.
  7. docs(phase): Indoor portal cell tracking shipped — closes #87 and the remaining wall-collision parts of #84 + #85; updates ISSUES.md, roadmap, CLAUDE.md; writes shipped-handoff doc.

7. Files touched

Modified:

  • src/AcDream.Core/Physics/PhysicsDataCache.csCellPhysics shape extended; CacheCellStruct signature change; new CacheBuilding; deleted TryFindContainingCell + AABB compute.
  • src/AcDream.Core/Physics/PhysicsEngine.cs — rename ResolveOutdoorCellIdResolveCellId; body rewritten to call CellTransit.FindCellList; 3 call sites in this file updated.
  • src/AcDream.Core/Physics/TransitionTypes.cs — call site update at line 1181.
  • src/AcDream.App/Rendering/GameWindow.cs — pass envCell into the extended CacheCellStruct; wire CacheBuilding at landblock load.

New:

  • src/AcDream.Core/Physics/CellTransit.cs — the new static class with FindCellList, FindTransitCellsSphere, CheckBuildingTransit, AddAllOutsideCells.
  • tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs — indoor portal traversal.
  • tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs — outdoor→indoor entry.
  • tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs — outdoor neighbours.
  • tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs — integration tests.

Rewritten:

  • tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs — renamed and ported to test the portal-based replacement.

Closed in ISSUES.md:

  • #87 (indoor cell tracking via AABB containment) — fully closed by this phase.
  • #85 (pass through walls outside→in) — closed; the outdoor→indoor entry path through BuildingObj handles this.
  • #84 (blocked by air indoors) — the wall-pass-through portion that remained after Phase D is closed here.

8. Error handling

  • Cell loaded without CellBSPPointInsideCellBsp(null, pt) per its current contract returns true, which over-matches. Add an explicit cellPhysics.CellBSP?.Root == null skip in FindTransitCellsSphere and in FindCellList's containment loop. The cell is treated as "not findable" until its BSP loads.
  • Portal references an unloaded OtherCellId — retail handles this with a "load hint" path that adds a null-cell entry for the streamer. We skip the add and continue; the next physics tick after streaming loads the cell picks it up. Document the one-tick latency as a known edge case.
  • Player teleports to a cell ID with no cached CellPhysics — fall back to AddAllOutsideCells (treat as outdoor) for that tick; the next tick after streaming loads the cell, portal traversal takes over.
  • No try/catch swallows. If the BSP traversal hits a malformed tree, the underlying BSPQuery asserts (Debug) or returns false (Release).

9. Testing

Unit tests (per commit)

  • CellPhysicsCellBspWiringTestsCacheCellStruct populates CellBSP, Portals, PortalPolygons, VisibleCellIds.
  • CellTransitFindTransitCellsSphereTests — synthetic two-cell portal pair:
    • Sphere overlapping portal poly → adds neighbour.
    • Sphere far from portal → doesn't add neighbour.
    • Sphere on wrong side of portal (per PortalSide) → doesn't add neighbour.
    • Sphere crossing exit portal (OtherCellId == 0xFFFF) → sets checkOutside = true.
  • CellTransitCheckBuildingTransitTests — outdoor sphere overlapping building portal plane + inside destination cell's CellBSP → adds the indoor cell.
  • CellTransitAddAllOutsideCellsTests — sphere at boundary X+Y, +XY, X+Y, XY of a 24m cell → 1, 2, or 4 cells in the result set.
  • CellTransitFindCellListTests — integration:
    • Indoor seed → returns matching indoor cell after portal walk.
    • Outdoor seed → returns matching landcell.
    • Outdoor seed near building portal → returns indoor cell via check_building_transit.
    • Indoor seed crossing exit portal → returns outdoor landcell.

Rewritten tests

  • The four ResolveOutdoorCellIdIndoorContainmentTests (Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge with CellTransitFindCellListTests.

Live test (user-driven)

Same launch incantation as Phase E:

$env:ACDREAM_PROBE_INDOOR_BSP     = "1"
$env:ACDREAM_PROBE_RESOLVE        = "1"
$env:ACDREAM_PROBE_CELL           = "1"
$env:ACDREAM_PROBE_CELL_CACHE     = "1"
$env:ACDREAM_DEVTOOLS             = "1"

Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below.


10. Acceptance

  1. Indoor walking — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision).
  2. Outdoor→indoor — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block.
  3. Indoor→outdoor — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync.
  4. Indoor→indoor — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues.
  5. [indoor-bsp] probe fires consistently during indoor walking — not just during jumps (the Phase D failure mode).
  6. dotnet build + dotnet test green with the new test suite. Pre-existing baseline of 8 failures unchanged.

11. Out of scope (deferred / explicit non-goals)

  • Parts/AABB variant of find_transit_cells — used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken.
  • VisibleCells cleanup filter — the optional last step of find_cell_list that strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use.
  • Multi-portal crossings within a single movement step — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up.
  • Unification with LoadedCell.Portals in AcDream.App.Rendering — two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase.
  • CellTransit for moving entities other than the player — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is.

12. Risks

  1. DAT field name mismatch. The pseudocode doc references CellStruct.CellBSP but DatReaderWriter may name it differently (e.g. cell_bsp, CellBsp, CellTree). Verify at plan-writing time by reading DatReaderWriter's CellStruct.cs (NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP.
  2. BuildingObj.Portals structure differs from indoor portals. Retail's BldPortal has more fields (OtherPortalId, ExactMatch). The DAT representation lives under LandBlockInfo.Buildings[...]; verify the field shape at plan-writing time.
  3. Sphere radius plumbing. FindTransitCellsSphere needs the player's sphere radius to test against the portal plane. The caller (Transition.FindEnvCollisions) has access via sp.GlobalSphere[0].Radius; plumb it through ResolveCellId's signature in the same commit that wires the call.
  4. Rename cost. Renaming ResolveOutdoorCellIdResolveCellId cascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior.
  5. Phase D test rewrites. The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves.

13. Phase name + roadmap placement

Proposed name: "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1).

Roadmap placement: add to docs/plans/2026-04-11-roadmap.md ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue.

Milestone: still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops).