diff --git a/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md new file mode 100644 index 0000000..cb454ef --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md @@ -0,0 +1,427 @@ +# 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](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` 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`): + +```csharp +public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags) +{ + /// Bit 2 of Flags. See research doc §"PortalSide flag semantics". + public bool PortalSide => (Flags & 2) == 0; +} +``` + +**`BuildingPhysics`** (new sealed class in `AcDream.Core.Physics`): + +```csharp +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform; + public required Matrix4x4 InverseWorldTransform; + public required IReadOnlyList 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(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: + +```csharp +public static class CellTransit +{ + /// + /// 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. + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + out CellSet candidateSet); + + /// + /// 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`. + /// + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + ref CellSet candidateSet); + + /// + /// 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. + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics buildingPhysics, + Vector3 worldSphereCenter, + float sphereRadius, + ref CellSet candidateSet); + + /// + /// 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. + /// + public static void AddAllOutsideCells( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + ref CellSet candidateSet); +} +``` + +`CellSet` is a small helper — either `HashSet` 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: + +```csharp +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 loaded** → `FindCellList` 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(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→indoor`** — `CacheBuilding` + `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 neighbours`** — `CellTransit.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 `ResolveOutdoorCellId` → `ResolveCellId` 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.cs` — `CellPhysics` shape extended; `CacheCellStruct` signature change; new `CacheBuilding`; deleted `TryFindContainingCell` + AABB compute. +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — rename `ResolveOutdoorCellId` → `ResolveCellId`; 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 `CellBSP`** — `PointInsideCellBsp(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) + +- **`CellPhysicsCellBspWiringTests`** — `CacheCellStruct` 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, +X−Y, −X+Y, −X−Y 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: + +```powershell +$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 `ResolveOutdoorCellId` → `ResolveCellId` 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).