# 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, +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).
