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>
This commit is contained in:
Erik 2026-05-19 16:32:21 +02:00
parent f0900ebe12
commit 48f0b26f62

View file

@ -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<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`):
```csharp
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`):
```csharp
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:
```csharp
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:
```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<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→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, +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:
```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).