# Indoor Portal-Based Cell Tracking Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace Phase D's AABB-based indoor cell tracking with retail-faithful portal-graph traversal, so walls block consistently inside buildings and CellId updates correctly when crossing doors (closes ISSUES.md #87 + remaining wall-collision parts of #84 + #85).

**Architecture:** A new pure-static `CellTransit` class in `AcDream.Core.Physics` ports retail's four functions: `find_cell_list` (top-level driver), `find_transit_cells` sphere variant (indoor portal walk), `check_building_transit` (outdoor→indoor entry), and `add_all_outside_cells` (outdoor neighbour expansion). Cell containment uses the already-ported `BSPQuery.PointInsideCellBsp` against each cell's `CellBSP` (a third BSP tree per cell, separate from `PhysicsBSP` and `DrawingBSP`). The existing `PhysicsEngine.ResolveOutdoorCellId` is renamed to `ResolveCellId`, its body rewritten to call `CellTransit.FindCellList`, and Phase D's `TryFindContainingCell` + AABB fields are deleted entirely.

**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter.DBObjs.EnvCell` + `DatReaderWriter.DBObjs.LandBlockInfo` for portal data sources, existing `BSPQuery.PointInsideCellBsp` for point-in-cell tests.

**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](../specs/2026-05-19-indoor-portal-cell-tracking-design.md)

**Research:** [`docs/research/acclient_indoor_transitions_pseudocode.md`](../../research/acclient_indoor_transitions_pseudocode.md) — the full algorithm in pseudocode, cross-referenced ACE + retail decomp.

---

## File Structure

| File | Action | Responsibility |
|---|---|---|
| `src/AcDream.Core/Physics/PortalInfo.cs` | create | `readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)` with `PortalSide` property (`(Flags & 2) == 0`). |
| `src/AcDream.Core/Physics/BuildingPhysics.cs` | create | `sealed class BuildingPhysics` holding `WorldTransform`, `InverseWorldTransform`, and an `IReadOnlyList<BldPortalInfo>` for outdoor→indoor entry. Plus `readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch)`. |
| `src/AcDream.Core/Physics/CellTransit.cs` | create | Static class with the four retail-ported functions: `FindCellList`, `FindTransitCellsSphere`, `CheckBuildingTransit`, `AddAllOutsideCells`. |
| `src/AcDream.Core/Physics/PhysicsDataCache.cs` | modify | Extend `CellPhysics` with `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`. Delete `LocalAabbMin/Max`. Change `CacheCellStruct` signature to accept `EnvCell envCell`. Delete `TryFindContainingCell`. Add `CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo>, Matrix4x4)` and `GetBuilding(uint)`. |
| `src/AcDream.Core/Physics/PhysicsEngine.cs` | modify | Rename `ResolveOutdoorCellId` → `ResolveCellId`. Add `sphereRadius` parameter. Body becomes `CellTransit.FindCellList` call. Update 2 internal callers. |
| `src/AcDream.Core/Physics/TransitionTypes.cs` | modify | Update call at line 1181 to pass sphere radius. |
| `src/AcDream.App/Rendering/GameWindow.cs` | modify | Update `CacheCellStruct` call at line 5384 to pass the `EnvCell`. Add `CacheBuilding` call inside the `lbInfo.Buildings` loop. |
| `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs` | create | `PortalSide` flag-decoding tests. |
| `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs` | create | Verify `CacheCellStruct` populates `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`. |
| `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs` | create | Indoor portal-graph walk: sphere overlaps portal → adds neighbour; far → doesn't add; wrong side → doesn't add; exit portal → marks checkOutside. |
| `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs` | create | Outdoor sphere overlapping building portal → adds indoor cell. |
| `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs` | create | Sphere at 24m landcell boundary edges → correct neighbour set. |
| `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs` | create | Integration: indoor → matching indoor cell; outdoor → matching landcell; outdoor near building → entering indoor cell; indoor → outdoor through exit portal. |
| `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` | rename + rewrite | Rename file to `ResolveCellIdTests.cs`; the 4 Phase D AABB tests are ported to use synthetic portal+CellBSP fixtures. |

---

## Plan-Time Reference Facts

These are facts an implementer needs and should NOT have to rediscover.

**Fact 1.** `BSPQuery.PointInsideCellBsp(node, point)` lives at [src/AcDream.Core/Physics/BSPQuery.cs:940](../../src/AcDream.Core/Physics/BSPQuery.cs:940). Signature: `public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point)`. Returns `true` for null node (treats it as fully solid — must NOT be reached by `FindCellList`; callers must guard `cellPhysics.CellBSP?.Root == null` themselves).

**Fact 2.** The existing `CellPhysics` is a `sealed class` (not record) with `required` + `init`-only properties. New fields can be added as non-required with defaults. Source: PhysicsDataCache.cs:422.

**Fact 3.** `envCell.CellPortals` (from `DatReaderWriter.DBObjs.EnvCell`) is an iterable where each portal has `.OtherCellId` (ushort low-16), `.PolygonId` (ushort), and `.Flags` (some integer type — cast to ushort). Confirmed by GameWindow.cs:5506-5511. The portal's `PolygonId` indexes `cellStruct.Polygons` (visible polys), NOT `cellStruct.PhysicsPolygons`. Confirmed by GameWindow.cs:5685-5689 comment.

**Fact 4.** `cellStruct.CellBSP` field name verification is **Task 0** (first task — must complete before any code changes). The DAT format definitely has this BSP per the retail header and pseudocode doc, but DatReaderWriter's exact C# property name needs confirming. If it's missing from the C# binding, escalate to controller.

**Fact 5.** `LandBlockInfo.Buildings` (from `DatReaderWriter.DBObjs.LandBlockInfo`) carries the building portal data — each Building has a portal list. The exact C# property names need confirming in Task 0 as well.

**Fact 6.** Existing `ResolvePolygons(polys, vertexArray)` helper at PhysicsDataCache.cs lines 231-275 is reusable for both `PhysicsPolygons` and `Polygons` (visible). The function is private static; either widen visibility (recommended) OR copy/paste the logic into a public wrapper.

**Fact 7.** The 3 `ResolveOutdoorCellId` call sites are:
- `PhysicsEngine.cs:254` (definition itself)
- `PhysicsEngine.cs:755` (inside `ResolveWithTransition`)
- `PhysicsEngine.cs:773` (inside `ResolveWithTransition` fallback path)
- `TransitionTypes.cs:1181` (inside `Transition.FindEnvCollisions`)

Plus 2 test references in `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` (the Phase D tests).

---

## Task 0: Verify DatReaderWriter field names (read-only)

**Files:** Read-only investigation. No commits.

- [ ] **Step 1: Confirm `CellStruct.CellBSP` exists**

Run from the worktree root:
```powershell
$cellStruct = [DatReaderWriter.Types.CellStruct]
$cellStruct.GetProperties() | Select-Object Name, PropertyType | Where-Object { $_.Name -match 'BSP|Bsp|Polygon|Portal|Vertex' }
```

OR find the DatReaderWriter source on disk:
```bash
find ~/.nuget/packages/datreaderwriter -name "*.cs" 2>/dev/null | head -5
find ~/.nuget/packages/datreader* -name "CellStruct*" 2>/dev/null
```

OR find the type by listing all instance properties at the call site. Add a temporary line to GameWindow.cs around line 5660 (where `cellStruct` is in scope):
```csharp
Console.WriteLine(string.Join(",", typeof(DatReaderWriter.Types.CellStruct).GetProperties().Select(p => p.Name)));
```
Build + run (don't commit). Read output. Remove the line.

Expected: a property name matching `CellBSP` / `CellBsp` (or similar). If found, note the EXACT name for Task 2.

- [ ] **Step 2: Confirm `LandBlockInfo.Buildings` shape**

Same method as Step 1, but for `DatReaderWriter.DBObjs.LandBlockInfo`. Look for `Buildings` (List or array). Inspect one Building's properties to find:
- A list of portals (probably `Portals` or `BldPortals`)
- The building's world transform (probably `Transform` or `Frame`)

For each Portal in a Building, find:
- `OtherCellId` (uint or ushort)
- `OtherPortalId` (ushort)
- `Flags` (ushort or some flags type)
- Optionally: `ExactMatch` (bool)

Document the exact property names. If a field is named differently than the spec assumed, note the mapping.

- [ ] **Step 3: Report findings**

Note (mentally or in scratch) the EXACT C# property names that will be used in subsequent tasks:
- `cellStruct.<CellBSP-name>` for the third BSP
- `lbInfo.Buildings` (or whatever name)
- Building's portal list property
- Building's transform property
- BldPortal field names

If `CellBSP` is missing OR `Buildings` is missing OR the shape is fundamentally different, STOP and report BLOCKED. The plan assumes both exist — if they don't, the controller needs to escalate (e.g., extend DatReaderWriter upstream).

Throughout the rest of this plan, the placeholder `<CellBSP>` refers to whatever the actual C# property name turned out to be. Adjust each task accordingly.

---

## Task 1: Add `PortalInfo` struct (TDD)

**Files:**
- Create: `src/AcDream.Core/Physics/PortalInfo.cs`
- Create: `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs`

- [ ] **Step 1: Write the failing test**

Create `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs`:

```csharp
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class PortalInfoTests
{
    [Fact]
    public void PortalSide_FlagsBit2Clear_ReturnsTrue()
    {
        // (Flags & 2) == 0 → PortalSide is true.
        var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
        Assert.True(portal.PortalSide);
    }

    [Fact]
    public void PortalSide_FlagsBit2Set_ReturnsFalse()
    {
        // (Flags & 2) != 0 → PortalSide is false.
        var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 2);
        Assert.False(portal.PortalSide);
    }

    [Fact]
    public void PortalSide_OtherBitsSet_FollowsOnlyBit2()
    {
        var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0xFF & ~2);
        Assert.True(portal.PortalSide);
    }

    [Fact]
    public void OtherCellId_StoredAsLowSixteenBits()
    {
        // OtherCellId is a low-16 cell index (or 0xFFFF for exit-to-outdoor).
        var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0);
        Assert.Equal((ushort)0xFFFF, portal.OtherCellId);
    }
}
```

- [ ] **Step 2: Run, expect failure**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"`
Expected: build fails (type not found).

- [ ] **Step 3: Create the type**

Create `src/AcDream.Core/Physics/PortalInfo.cs`:

```csharp
namespace AcDream.Core.Physics;

/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
/// mirroring retail's <c>CCellStruct.portals</c> array.
///
/// <para>
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
/// "exit to outdoor world" (the player crosses this portal to leave
/// the building).
/// </para>
///
/// <para>
/// <see cref="PolygonId"/> indexes the OWNING cell's
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
/// polys).
/// </para>
///
/// <para>
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
/// <c>(Flags &amp; 2) == 0</c> → portal's polygon normal points INTO
/// the owning cell (so dist &gt; 0 in cell-local space means "outside
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
/// load-hint path for unloaded neighbours.
/// </para>
/// </summary>
public readonly struct PortalInfo
{
    public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
    {
        OtherCellId = otherCellId;
        PolygonId   = polygonId;
        Flags       = flags;
    }

    public ushort OtherCellId { get; }
    public ushort PolygonId   { get; }
    public ushort Flags       { get; }

    /// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
    public bool PortalSide => (Flags & 2) == 0;
}
```

- [ ] **Step 4: Run tests, expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"`
Expected: 4 tests passing.

- [ ] **Step 5: No commit yet** — bundle with Task 2 + Task 3 into one data-wiring commit.

---

## Task 2: Extend `CellPhysics` with portal fields (TDD)

**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the `CellPhysics` class around line 422)
- Create: `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`

- [ ] **Step 1: Locate `CellPhysics` and confirm shape**

Open `src/AcDream.Core/Physics/PhysicsDataCache.cs` and locate the `CellPhysics` class around line 422. Confirm:
- It's a `sealed class` with `required` + `init`-only properties.
- It currently has `BSP`, `PhysicsPolygons`, `Vertices`, `WorldTransform`, `InverseWorldTransform`, `Resolved`, `LocalAabbMin`, `LocalAabbMax`.

- [ ] **Step 2: Add new fields, delete AABB fields**

Replace the class body to:
1. ADD: `CellBSP` (`PhysicsBSPTree?`, init-only, nullable).
2. ADD: `Portals` (`IReadOnlyList<PortalInfo>`, init-only, default empty).
3. ADD: `PortalPolygons` (`Dictionary<ushort, ResolvedPolygon>?`, init-only, default null).
4. ADD: `VisibleCellIds` (`IReadOnlySet<uint>`, init-only, default empty).
5. DELETE: `LocalAabbMin` and `LocalAabbMax` (and their XML docs).

Concrete edit shape (the existing class continues to start with `public sealed class CellPhysics { ... }`):

```csharp
public sealed class CellPhysics
{
    // ── Pre-existing fields (unchanged) ────────────────────────────────
    public PhysicsBSPTree? BSP { get; init; }
    public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
    public VertexArray? Vertices { get; init; }
    public required Matrix4x4 WorldTransform { get; init; }
    public required Matrix4x4 InverseWorldTransform { get; init; }
    public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }

    // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────

    /// <summary>
    /// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
    /// (point-in-cell tests). Separate tree from <see cref="BSP"/>
    /// (collision) and from the renderer's drawing-BSP.
    /// Source: <c>cellStruct.&lt;CellBSP-property-name&gt;</c> at cache time.
    /// Nullable: cells without a CellBSP cannot participate in portal
    /// containment and are skipped by <see cref="CellTransit"/>.
    /// </summary>
    public PhysicsBSPTree? CellBSP { get; init; }

    /// <summary>
    /// Portal connections to neighbouring cells, in cell-local space.
    /// Default: empty list. Source: <c>envCell.CellPortals</c>.
    /// </summary>
    public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();

    /// <summary>
    /// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
    /// keyed by polygon id. Distinct from <see cref="Resolved"/> which
    /// holds <c>PhysicsPolygons</c>. Portal lookup via
    /// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
    /// Nullable when the cell has no visible polys (rare).
    /// </summary>
    public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }

    /// <summary>
    /// The cell ids visible from this cell (low-16 indexes, combined
    /// with the owning landblock prefix). Populated from
    /// <c>envCell.VisibleCells</c>. Unused this phase; reserved for the
    /// optional <c>find_cell_list</c> visibility filter.
    /// </summary>
    public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}
```

**Important:** the AABB fields (`LocalAabbMin`, `LocalAabbMax`) are DELETED in this edit. Their XML docs go too. Any references to them anywhere in the codebase will fail the build in Task 7 (intentional — the build break shows you missed a deletion site).

- [ ] **Step 3: Write the parity test (RED first)**

Create `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`:

```csharp
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class CellPhysicsPortalWiringTests
{
    [Fact]
    public void NewFields_HaveSensibleDefaults()
    {
        // Phase 2 added CellBSP / Portals / PortalPolygons / VisibleCellIds.
        // Default-initialized values must not crash callers.
        var cp = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
        };

        Assert.Null(cp.CellBSP);
        Assert.Empty(cp.Portals);
        Assert.Null(cp.PortalPolygons);
        Assert.Empty(cp.VisibleCellIds);
    }

    [Fact]
    public void NewFields_AcceptInitValues()
    {
        var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);

        var cp = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
            Portals = new[] { portal },
            VisibleCellIds = new System.Collections.Generic.HashSet<uint> { 0xA9B40101 },
        };

        Assert.Single(cp.Portals);
        Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
        Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
    }
}
```

- [ ] **Step 4: Run tests, expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring"`
Expected: 2 tests passing.

- [ ] **Step 5: Verify build is still otherwise green (deletion didn't break anything yet)**

Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`

If references to `LocalAabbMin` / `LocalAabbMax` exist outside the deleted block, you'll see compile errors. Those error locations are EXACTLY what you must fix in Task 7. For now, the AcDream.Core.csproj might not build green yet — that's expected. Note the error locations.

Expected build state: **may have errors** referencing the deleted AABB fields. That's fine; Task 7 closes them.

- [ ] **Step 6: No commit yet** — Task 3 finishes the data wiring.

---

## Task 3: Extend `CacheCellStruct` to populate portal data (TDD)

**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the `CacheCellStruct` method around lines 131-180)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (call site at line 5384)
- Extend: `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`

- [ ] **Step 1: Replace `ResolvePolygons` visibility**

In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, find the existing `private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(...)` at line 231. Change `private` to `internal`. The function stays as-is otherwise — same signature, same body. This makes it reusable for both `PhysicsPolygons` and the new `PortalPolygons` resolution.

- [ ] **Step 2: Change `CacheCellStruct` signature**

Find the existing `CacheCellStruct` method at PhysicsDataCache.cs:131. Change the signature from:

```csharp
public void CacheCellStruct(uint envCellId, CellStruct cellStruct, Matrix4x4 worldTransform)
```

to:

```csharp
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)
```

Add the `using DatReaderWriter.DBObjs;` at the top of the file if not already there.

- [ ] **Step 3: Replace the body**

Inside `CacheCellStruct`, replace the body. New body:

```csharp
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
                            CellStruct cellStruct, Matrix4x4 worldTransform)
{
    if (_cellStruct.ContainsKey(envCellId)) return;
    if (cellStruct.PhysicsBSP?.Root is null) return;

    Matrix4x4.Invert(worldTransform, out var inverseTransform);

    var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);

    // Visible polygons — portals reference these (NOT PhysicsPolygons).
    var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);

    // Portal list from envCell.CellPortals.
    var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
    foreach (var p in envCell.CellPortals)
    {
        portals.Add(new PortalInfo(
            otherCellId: p.OtherCellId,
            polygonId:   p.PolygonId,
            flags:       (ushort)p.Flags));
    }

    // VisibleCells set — populated for future use; not consulted this phase.
    var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
    if (envCell.VisibleCells is not null)
    {
        uint lbPrefix = envCellId & 0xFFFF0000u;
        foreach (var lowId in envCell.VisibleCells.Keys)
            visibleCellIds.Add(lbPrefix | lowId);
    }

    _cellStruct[envCellId] = new CellPhysics
    {
        BSP = cellStruct.PhysicsBSP,
        PhysicsPolygons = cellStruct.PhysicsPolygons,
        Vertices = cellStruct.VertexArray,
        WorldTransform = worldTransform,
        InverseWorldTransform = inverseTransform,
        Resolved = resolved,
        // ── Phase 2 portal fields ──
        CellBSP = cellStruct.<CellBSP-property-name>,   // ← REPLACE with actual property name from Task 0
        Portals = portals,
        PortalPolygons = portalPolygons,
        VisibleCellIds = visibleCellIds,
    };

    if (PhysicsDiagnostics.ProbeCellCacheEnabled)
    {
        var root = cellStruct.PhysicsBSP?.Root;
        int bspRootPolyCount   = root?.Polygons?.Count ?? 0;
        bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;

        // Recursive walk: count total leaf poly references + how many of
        // those poly IDs are absent from the resolved dict. If
        // bspTotalLeafPolys == 0 the BSP has no collidable polys at all.
        int bspTotalLeafPolys = 0;
        int bspUnmatchedIds   = 0;
        if (root is not null)
        {
            var stack = new System.Collections.Generic.Stack<DatReaderWriter.Types.PhysicsBSPNode>();
            stack.Push(root);
            while (stack.Count > 0)
            {
                var n = stack.Pop();
                if (n.Polygons is not null)
                {
                    foreach (var pid in n.Polygons)
                    {
                        bspTotalLeafPolys++;
                        if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
                    }
                }
                if (n.PosNode is not null) stack.Push(n.PosNode);
                if (n.NegNode is not null) stack.Push(n.NegNode);
            }
        }

        var bs = root?.BoundingSphere;
        string bsStr = bs is null
            ? "bsphere=n/a"
            : System.FormattableString.Invariant(
                $"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");

        var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);

        // Phase 2: dropped aabbMin/aabbMax (deleted in Task 2). Added
        // portal/visible counts.
        Console.WriteLine(System.FormattableString.Invariant(
            $"[cell-cache] envCellId=0x{envCellId:X8} " +
            $"physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} " +
            $"resolvedCount={resolved.Count} " +
            $"bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} " +
            $"{bsStr} " +
            $"portalCount={portals.Count} " +
            $"visibleCells={visibleCellIds.Count} " +
            $"cellBspRoot={(cellStruct.<CellBSP-property-name>?.Root is null ? "null" : "ok")} " +
            $"worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
    }
}
```

**Substitution:** `<CellBSP-property-name>` appears twice in the snippet above — replace BOTH with the actual property name discovered in Task 0 (likely `CellBSP` or `CellBsp`).

- [ ] **Step 4: Update the GameWindow call site**

In `src/AcDream.App/Rendering/GameWindow.cs` at line 5384, find the existing call:

```csharp
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
```

Replace with:

```csharp
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
```

(The `envCell` variable is already in scope at this site — it's the loop variable from the surrounding `foreach` over EnvCells.)

- [ ] **Step 5: Extend the parity test with a portal-population check**

Append to `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`:

```csharp
    [Fact]
    public void CellPhysics_PortalsRoundTrip()
    {
        // Two portals: one indoor (OtherCellId=0x0101), one exit (OtherCellId=0xFFFF).
        var portals = new[]
        {
            new PortalInfo(otherCellId: 0x0101, polygonId: 7, flags: 0),
            new PortalInfo(otherCellId: 0xFFFF, polygonId: 8, flags: 2),
        };

        var cp = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
            Portals = portals,
        };

        Assert.Equal(2, cp.Portals.Count);
        Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
        Assert.True(cp.Portals[0].PortalSide);
        Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
        Assert.False(cp.Portals[1].PortalSide);
    }
```

- [ ] **Step 6: Build + test green**

Run: `dotnet build && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring|FullyQualifiedName~PortalInfo"`

Expected: 7 tests passing (4 PortalInfo + 3 CellPhysicsPortalWiring). The full `dotnet build` may still have errors from `TryFindContainingCell` and AABB references that Task 7 cleans up — that's expected. The targeted filter must pass.

- [ ] **Step 7: Commit (data wiring)**

```
git add src/AcDream.Core/Physics/PortalInfo.cs `
        src/AcDream.Core/Physics/PhysicsDataCache.cs `
        src/AcDream.App/Rendering/GameWindow.cs `
        tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs `
        tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs

git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics

Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP for
point-in-cell tests), Portals (from envCell.CellPortals), PortalPolygons
(resolved cellStruct.Polygons — portals reference visible polys, not
PhysicsPolygons), and VisibleCellIds (populated for future use). Deletes
the Phase D LocalAabbMin/Max fields; CacheCellStruct's AABB compute is
gone.

Build is intentionally not green yet — references to the deleted AABB
fields in PhysicsEngine and tests will be removed in the integration commit
that wires CellTransit.FindCellList. This data-wiring step lands first so
the new infrastructure is in place before the consumer.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Port `CellTransit.FindTransitCellsSphere` (indoor portal walk, TDD)

**Files:**
- Create: `src/AcDream.Core/Physics/CellTransit.cs`
- Create: `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs`

- [ ] **Step 1: Write the failing tests**

Create `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class CellTransitFindTransitCellsSphereTests
{
    // Synthetic 2-cell scenario:
    //   Cell A at world origin; cell B 5m east of A.
    //   Portal poly at x=2.5 (the wall between A and B), normal pointing +X (out of A).
    //   Both cells are 5x5x5 m AABB.

    private const float EPSILON = 0.02f;

    private static (CellPhysics cellA, CellPhysics cellB) MakeAdjacentCells()
    {
        // Cell A: at world origin; portal poly at local x=2.5 (right wall), normal +X.
        var portalPolyA = new ResolvedPolygon
        {
            Vertices  = new[]
            {
                new Vector3(2.5f, -2.5f,  0f),
                new Vector3(2.5f,  2.5f,  0f),
                new Vector3(2.5f,  2.5f,  5f),
                new Vector3(2.5f, -2.5f,  5f),
            },
            Plane     = new Plane(new Vector3(1, 0, 0), -2.5f),  // x = 2.5
            NumPoints = 4,
            SidesType = DatReaderWriter.Enums.CullMode.None,
        };

        var cellA = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new Dictionary<ushort, ResolvedPolygon>(),
            PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPolyA },
            Portals = new[]
            {
                // Portal to Cell B (low-16 = 0x0101). Flags=0 → PortalSide=true.
                new PortalInfo(otherCellId: 0x0101, polygonId: 10, flags: 0),
            },
        };

        // Cell B: 5m east in world; same portal polygon mirrored.
        var bWorldT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
        Matrix4x4.Invert(bWorldT, out var bInvT);
        var cellB = new CellPhysics
        {
            WorldTransform = bWorldT,
            InverseWorldTransform = bInvT,
            Resolved = new Dictionary<ushort, ResolvedPolygon>(),
            // Cell B's CellBSP — null means point-in-cell returns "outside" for the test path.
            // For these tests we don't need the BSP since we're testing the load-hint and
            // add-by-sphere paths.
        };

        return (cellA, cellB);
    }

    [Fact]
    public void SphereInsideCellA_NearPortal_AddsCellB()
    {
        var (cellA, cellB) = MakeAdjacentCells();
        var cache = new PhysicsDataCache();
        cache.RegisterCellStructForTest(0xA9B40100u, cellA);
        cache.RegisterCellStructForTest(0xA9B40101u, cellB);

        // Sphere center near the portal plane (local x=2.0, sphere radius=0.5).
        // Sphere reaches x=2.5 which is the portal plane → straddles.
        var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
        float sphereRadius = 0.5f;

        var candidates = new HashSet<uint>();
        CellTransit.FindTransitCellsSphere(
            cache,
            currentCell: cellA,
            currentCellId: 0xA9B40100u,
            worldSphereCenter,
            sphereRadius,
            candidates,
            out bool exitOutside);

        Assert.Contains(0xA9B40101u, candidates);
        Assert.False(exitOutside);
    }

    [Fact]
    public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB()
    {
        var (cellA, cellB) = MakeAdjacentCells();
        var cache = new PhysicsDataCache();
        cache.RegisterCellStructForTest(0xA9B40100u, cellA);
        cache.RegisterCellStructForTest(0xA9B40101u, cellB);

        // Sphere far from portal (local x=-1.0, sphere radius=0.5).
        // Sphere reach extends only to x=-0.5 — nowhere near portal at x=2.5.
        var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f);

        var candidates = new HashSet<uint>();
        CellTransit.FindTransitCellsSphere(
            cache,
            currentCell: cellA,
            currentCellId: 0xA9B40100u,
            worldSphereCenter,
            sphereRadius: 0.5f,
            candidates,
            out bool exitOutside);

        Assert.DoesNotContain(0xA9B40101u, candidates);
    }

    [Fact]
    public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside()
    {
        // Modify cell A to have its second portal be an EXIT (OtherCellId = 0xFFFF).
        var (cellA, _) = MakeAdjacentCells();
        var exitOnly = new CellPhysics
        {
            WorldTransform = cellA.WorldTransform,
            InverseWorldTransform = cellA.InverseWorldTransform,
            Resolved = cellA.Resolved,
            PortalPolygons = cellA.PortalPolygons,
            Portals = new[]
            {
                new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0),
            },
        };

        var cache = new PhysicsDataCache();
        cache.RegisterCellStructForTest(0xA9B40100u, exitOnly);

        var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
        var candidates = new HashSet<uint>();

        CellTransit.FindTransitCellsSphere(
            cache,
            currentCell: exitOnly,
            currentCellId: 0xA9B40100u,
            worldSphereCenter,
            sphereRadius: 0.5f,
            candidates,
            out bool exitOutside);

        Assert.True(exitOutside);
    }
}
```

- [ ] **Step 2: Run, expect failure**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"`
Expected: build fails (`CellTransit` not found).

- [ ] **Step 3: Implement `CellTransit.FindTransitCellsSphere`**

Create `src/AcDream.Core/Physics/CellTransit.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Types;

namespace AcDream.Core.Physics;

/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
/// ported from retail's <c>CObjCell::find_cell_list</c> family
/// (sphere variant for the player's single foot sphere).
///
/// <para>
/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
/// faithful point-in-cell tests via
/// <see cref="BSPQuery.PointInsideCellBsp"/>. Walks the portal graph
/// starting from a given current cell to find which cells a moving
/// sphere overlaps.
/// </para>
///
/// <para>
/// Reference pseudocode:
/// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
/// (2026-04-13). Retail decomp: <c>CEnvCell::find_transit_cells</c>
/// (sphere variant) at <c>acclient_2013_pseudo_c.txt</c>.
/// </para>
/// </summary>
public static class CellTransit
{
    /// <summary>
    /// Small radius padding matching retail's <c>EPSILON</c> usage in the
    /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
    /// </summary>
    private const float EPSILON = 0.02f;

    /// <summary>
    /// Indoor portal-neighbour expansion. For each portal of
    /// <paramref name="currentCell"/>, test whether the sphere overlaps
    /// the portal polygon's plane in cell-local space. If so, add the
    /// neighbour cell to <paramref name="candidates"/>.
    ///
    /// <para>
    /// Ported from <c>CEnvCell::find_transit_cells</c> (sphere variant)
    /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
    /// </para>
    /// </summary>
    /// <param name="cache">The physics data cache (for neighbour lookups).</param>
    /// <param name="currentCell">The cell whose portals are walked.</param>
    /// <param name="currentCellId">The full id (with landblock prefix) of
    ///   <paramref name="currentCell"/>. Used to resolve neighbour ids by
    ///   combining the prefix with <see cref="PortalInfo.OtherCellId"/>.</param>
    /// <param name="worldSphereCenter">Player's foot-sphere center in world space.</param>
    /// <param name="sphereRadius">Player's foot-sphere radius.</param>
    /// <param name="candidates">Set to add neighbour cell ids to.</param>
    /// <param name="exitOutside">Set to true if the sphere straddles an
    ///   exit portal (<c>OtherCellId == 0xFFFF</c>) — the caller should
    ///   then expand outdoor neighbour cells via <see cref="AddAllOutsideCells"/>.</param>
    public static void FindTransitCellsSphere(
        PhysicsDataCache cache,
        CellPhysics currentCell,
        uint currentCellId,
        Vector3 worldSphereCenter,
        float sphereRadius,
        HashSet<uint> candidates,
        out bool exitOutside)
    {
        exitOutside = false;
        if (currentCell.PortalPolygons is null) return;

        uint lbPrefix = currentCellId & 0xFFFF0000u;
        float rad = sphereRadius + EPSILON;

        // Cell-local sphere center.
        var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);

        foreach (var portal in currentCell.Portals)
        {
            if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
                continue;

            // Signed distance from sphere center to portal plane (cell-local).
            float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;

            if (portal.OtherCellId == 0xFFFF)
            {
                // Exit portal. Sphere must straddle the plane.
                if (dist > -rad && dist < rad)
                {
                    exitOutside = true;
                    // Don't break — there may be more portals to check.
                }
                continue;
            }

            uint otherId = lbPrefix | portal.OtherCellId;
            var otherCell = cache.GetCellStruct(otherId);

            if (otherCell is not null)
            {
                // Neighbour is loaded. Test containment via PointInsideCellBsp
                // in the OTHER cell's local space.
                var otherLocal = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);

                // We don't yet have sphere_intersects_cell ported. Use the
                // load-hint sphere-plane heuristic from the research doc's
                // "otherCell == null" branch as a conservative add — once
                // the sphere is near the portal plane and on the "exit" side
                // (per PortalSide), the neighbour is a valid candidate.
                if (portal.PortalSide ? dist > -rad : dist < rad)
                {
                    candidates.Add(otherId);
                }
            }
            else
            {
                // Load-hint path: neighbour not yet cached. Mirrors retail's
                // load-hint branch — add by plane-side test only.
                if (portal.PortalSide ? dist > -rad : dist < rad)
                {
                    candidates.Add(otherId);
                }
            }
        }
    }
}
```

**Note on `sphere_intersects_cell`:** retail's algorithm uses `CellBSP.sphere_intersects_cell_bsp` (returns `Inside`/`Crossing`/`Outside`) for the neighbour-containment test. Our `BSPQuery` doesn't currently expose that operation — only `PointInsideCellBsp` and the collision-side `FindCollisions`. The plane-side heuristic above is a conservative approximation: it adds a candidate whenever the sphere is close to the portal and on the right side. Combined with `FindCellList`'s subsequent point-in-cell test (Task 6) which filters to a single winning cell via `PointInsideCellBsp`, the conservative add is safe.

A more retail-faithful implementation would add `BSPQuery.SphereIntersectsCellBsp` (~80 LOC). Deferred to a follow-up if our heuristic produces visible bugs.

- [ ] **Step 4: Run tests, expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"`
Expected: 3 tests passing.

- [ ] **Step 5: No commit yet** — bundle with Task 5 + 6 + 7 into one CellTransit commit.

---

## Task 5: Port `CellTransit.AddAllOutsideCells` (TDD)

**Files:**
- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
- Create: `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs`

- [ ] **Step 1: Write the failing tests**

Create `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class CellTransitAddAllOutsideCellsTests
{
    // Outdoor landcells are 24×24m. Cell ids low-16 are 0x01..0x40 in row-major
    // order (8 cells wide, 8 cells tall per landblock). Cell offset within
    // landblock: cellY = (low - 1) % 8, cellX = (low - 1) / 8 (per the AC2D
    // reference; verify with research doc).

    [Fact]
    public void SphereWellInsideCell_AddsOneCell()
    {
        // Player at world (12, 12, 0) — middle of cell (0, 0) of landblock 0xA9B40000.
        // Cell low-16 id = 0x0001 (first outdoor cell).
        var candidates = new HashSet<uint>();
        CellTransit.AddAllOutsideCells(
            worldSphereCenter: new Vector3(12f, 12f, 0f),
            sphereRadius: 0.5f,
            currentCellId: 0xA9B40001u,
            candidates);

        Assert.Single(candidates);
        Assert.Contains(0xA9B40001u, candidates);
    }

    [Fact]
    public void SphereAtCellEastBoundary_AddsTwoCells()
    {
        // Player at world (23.6, 12, 0) — at the +X edge of cell (0,0). Sphere
        // radius 0.5 → reaches X=24.1 which is in cell (1,0).
        var candidates = new HashSet<uint>();
        CellTransit.AddAllOutsideCells(
            worldSphereCenter: new Vector3(23.6f, 12f, 0f),
            sphereRadius: 0.5f,
            currentCellId: 0xA9B40001u,
            candidates);

        Assert.Equal(2, candidates.Count);
        Assert.Contains(0xA9B40001u, candidates);
        // Cell at (1, 0): low-16 id = 0x0009 (cell index = 8 → low+8 = 0x0009).
        Assert.Contains(0xA9B40009u, candidates);
    }

    // Additional boundary tests (corner: 4 cells; +Y edge: 2 cells) intentionally
    // omitted from the plan to keep the commit small. Add them once the basic
    // shape works.
}
```

- [ ] **Step 2: Add the function to `CellTransit`**

Append to `src/AcDream.Core/Physics/CellTransit.cs`:

```csharp
    /// <summary>
    /// Outdoor neighbour expansion. Ported from
    /// <c>CLandCell::add_all_outside_cells</c> (sphere variant) per the
    /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
    ///
    /// <para>
    /// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
    /// within a landblock is computed from local X/Y mod 24. The sphere
    /// adds the primary cell plus up to 3 neighbours when the radius
    /// reaches a cell boundary.
    /// </para>
    /// </summary>
    public static void AddAllOutsideCells(
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId,
        HashSet<uint> candidates)
    {
        const float CellSize = 24f;

        uint lbPrefix = currentCellId & 0xFFFF0000u;

        // Compute the landblock's world XY origin from the landblock id.
        // Landblock byte X = (lbPrefix >> 24) & 0xFF, Y = (lbPrefix >> 16) & 0xFF.
        // Each landblock is 192×192m at world (lbX * 192, lbY * 192).
        // The "current landblock" depends on world position — if the sphere
        // crosses landblock boundaries we'd need to adjust the prefix; for
        // now assume the sphere stays within the current landblock.
        float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f;
        float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f;
        float localX = worldSphereCenter.X - lbXf;
        float localY = worldSphereCenter.Y - lbYf;

        // Within-cell local 2D.
        float cellLocalX = localX % CellSize;
        float cellLocalY = localY % CellSize;
        float minRad = sphereRadius;
        float maxRad = CellSize - sphereRadius;

        // Grid coordinates.
        int gridX = (int)(localX / CellSize);
        int gridY = (int)(localY / CellSize);
        if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;

        AddOutsideCell(candidates, lbPrefix, gridX, gridY);

        // Boundary checks (matches research doc's check_add_cell_boundary).
        if (cellLocalX > maxRad)
        {
            AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
            if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
            if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
        }
        if (cellLocalX < minRad)
        {
            AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
            if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
            if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
        }
        if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
        if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
    }

    private static void AddOutsideCell(HashSet<uint> candidates, uint lbPrefix, int gridX, int gridY)
    {
        if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;

        // Cell index within landblock: row-major (X * 8 + Y) + 1, per AC's convention.
        uint low = (uint)(gridX * 8 + gridY + 1);
        candidates.Add(lbPrefix | low);
    }
```

- [ ] **Step 3: Run tests, expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitAddAllOutsideCells"`
Expected: 2 tests passing.

- [ ] **Step 4: No commit yet.**

---

## Task 6: Port `CellTransit.FindCellList` (top-level driver, TDD)

**Files:**
- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
- Create: `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs`

- [ ] **Step 1: Write the failing tests**

Create `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class CellTransitFindCellListTests
{
    [Fact]
    public void IndoorSeed_PointInsideCellBsp_ReturnsCurrentCell()
    {
        var cache = new PhysicsDataCache();
        // Synthetic cell with no portals; CellBSP is null so PointInsideCellBsp
        // would return true — but we guard CellBSP?.Root == null and treat
        // missing BSP as "not findable" → fall through.
        var cell = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new Dictionary<ushort, ResolvedPolygon>(),
        };
        cache.RegisterCellStructForTest(0xA9B40100u, cell);

        uint result = CellTransit.FindCellList(
            cache,
            worldSphereCenter: Vector3.Zero,
            sphereRadius: 0.5f,
            currentCellId: 0xA9B40100u);

        // No CellBSP → falls back to the input cell id.
        Assert.Equal(0xA9B40100u, result);
    }

    [Fact]
    public void OutdoorSeed_Returns_OutdoorLandcell()
    {
        var cache = new PhysicsDataCache();
        // Outdoor seed: low-16 < 0x0100. No CellPhysics needed for landcells.
        uint result = CellTransit.FindCellList(
            cache,
            worldSphereCenter: new Vector3(12f, 12f, 0f),
            sphereRadius: 0.5f,
            currentCellId: 0xA9B40001u);

        Assert.Equal(0xA9B40001u, result);
    }
}
```

- [ ] **Step 2: Add `FindCellList` to `CellTransit`**

Append to `src/AcDream.Core/Physics/CellTransit.cs`:

```csharp
    /// <summary>
    /// Top-level cell-tracking driver, ported from retail's
    /// <c>CObjCell::find_cell_list</c> (sphere variant).
    ///
    /// <para>
    /// Walks the portal graph from <paramref name="currentCellId"/>,
    /// finds the cell whose <see cref="CellPhysics.CellBSP"/> contains
    /// the sphere center, and returns its full id (landblock-prefixed).
    /// Falls back to <paramref name="currentCellId"/> when no candidate
    /// matches.
    /// </para>
    ///
    /// <para>
    /// Pseudocode reference:
    /// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
    /// §"Overall Driver: find_cell_list".
    /// </para>
    /// </summary>
    public static uint FindCellList(
        PhysicsDataCache cache,
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId)
    {
        var candidates = new HashSet<uint>();
        uint currentLow = currentCellId & 0xFFFFu;

        if (currentLow >= 0x0100u)
        {
            // Indoor seed.
            var currentCell = cache.GetCellStruct(currentCellId);
            if (currentCell is null) return currentCellId;

            candidates.Add(currentCellId);

            // BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
            var pending = new Queue<uint>();
            pending.Enqueue(currentCellId);
            int maxIterations = 16;  // hard cap; portal graphs are small
            while (pending.Count > 0 && maxIterations-- > 0)
            {
                uint cellId = pending.Dequeue();
                var cell = cache.GetCellStruct(cellId);
                if (cell is null) continue;

                var sizeBefore = candidates.Count;
                FindTransitCellsSphere(
                    cache, cell, cellId, worldSphereCenter, sphereRadius,
                    candidates, out bool exitOutside);

                // For each NEW candidate, enqueue it.
                if (candidates.Count > sizeBefore)
                {
                    // Snapshot the new candidates to avoid mutating during iteration.
                    foreach (var c in candidates)
                    {
                        if (c != cellId)  // skip seed
                            pending.Enqueue(c);
                    }
                }

                if (exitOutside)
                {
                    // Add neighbour outdoor cells too.
                    AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
                }
            }
        }
        else
        {
            // Outdoor seed.
            AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
            // TODO: check_building_transit hookup at Task 7.
        }

        // Containment test: for each candidate, transform worldSphereCenter to
        // local and test PointInsideCellBsp.
        foreach (uint candId in candidates)
        {
            var cand = cache.GetCellStruct(candId);
            if (cand?.CellBSP?.Root is null) continue;

            var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
            if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
                return candId;
        }

        // No cell contained the sphere center. Stay in the input cell.
        return currentCellId;
    }
```

- [ ] **Step 3: Run tests, expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindCellList"`
Expected: 2 tests passing.

- [ ] **Step 4: No commit yet.**

---

## Task 7: Wire `CellTransit.FindCellList` into `ResolveCellId`, delete AABB containment

**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs`
- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs`
- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs`
- Rename + rewrite: `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` → `tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs`

- [ ] **Step 1: Rename `ResolveOutdoorCellId` and rewrite body**

In `src/AcDream.Core/Physics/PhysicsEngine.cs`, find `ResolveOutdoorCellId` at line 254. Rename to `ResolveCellId` and replace the body. New definition:

```csharp
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
/// given world position via retail's portal-graph traversal. Delegates
/// to <see cref="CellTransit.FindCellList"/>.
///
/// <para>
/// Replaces Phase D's <c>ResolveOutdoorCellId</c> which used AABB
/// containment — see <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
/// for the design.
/// </para>
/// </summary>
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
    if (fallbackCellId == 0) return 0;
    if (DataCache is null) return fallbackCellId;

    return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
}
```

- [ ] **Step 2: Update the two internal callers in `PhysicsEngine.cs`**

Find lines 755 and 773 (or thereabouts after the rename). The current calls are:

```csharp
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
```

Plumb the sphere radius through. The caller is inside `ResolveWithTransition`, and `sp.GlobalSphere[0].Radius` is accessible via the `sp` (SpherePath) variable. Update both lines:

```csharp
ResolveCellId(sp.CheckPos, sp.GlobalSphere[0].Radius, sp.CheckCellId),
```

- [ ] **Step 3: Update the `TransitionTypes.cs` call**

At `src/AcDream.Core/Physics/TransitionTypes.cs:1181`, the existing call:

```csharp
uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
```

Replace with:

```csharp
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId);
```

The `sphereRadius` local is already in scope at line 1186 (`float sphereRadius = sp.GlobalSphere[0].Radius;`). The replacement uses it directly.

- [ ] **Step 4: Delete `TryFindContainingCell` from `PhysicsDataCache`**

In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, find the `TryFindContainingCell` method (around line 295). Delete the entire method including its XML doc.

- [ ] **Step 5: Rebuild — should be green now**

Run: `dotnet build`

If errors remain referencing `LocalAabbMin` / `LocalAabbMax` / `TryFindContainingCell` outside the deleted code, fix them. Most likely candidates: the WorldPicker tests if they constructed CellPhysics with those fields, or any other consumer that snuck in.

- [ ] **Step 6: Rename + rewrite the Phase D test file**

Rename `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` to `tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs` (file rename — use `git mv`).

Replace the class body. The old AABB-based tests assert containment behavior that no longer exists. New tests verify the portal-traversal-based behavior:

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class ResolveCellIdTests
{
    [Fact]
    public void ResolveCellId_FallbackZero_ReturnsZero()
    {
        var engine = new PhysicsEngine();
        uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0u);
        Assert.Equal(0u, result);
    }

    [Fact]
    public void ResolveCellId_NoDataCache_ReturnsFallback()
    {
        // Build a PhysicsEngine without setting DataCache (default: null).
        var engine = new PhysicsEngine { DataCache = null };
        uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0x00000001u);
        Assert.Equal(0x00000001u, result);
    }

    [Fact]
    public void ResolveCellId_OutdoorSeedNoLandblock_ReturnsFallback()
    {
        var engine = new PhysicsEngine();
        uint result = engine.ResolveCellId(
            new Vector3(100, 100, 0),
            sphereRadius: 0.5f,
            fallbackCellId: 0xA9B40001u);
        // No cells cached, no landblock added → AddAllOutsideCells produces 1
        // candidate (the input cell) but PointInsideCellBsp on null CellBSP skips
        // → returns fallback.
        Assert.Equal(0xA9B40001u, result);
    }
}
```

(The original Phase D tests covered specific behaviors that are now better covered by `CellTransitFindCellListTests` and `CellTransitFindTransitCellsSphereTests`. Don't try to port them 1:1.)

- [ ] **Step 7: Build + full test sweep**

Run: `dotnet build && dotnet test`

Expected:
- Build green (0 errors).
- All new tests pass.
- 8 pre-existing failures unchanged (baseline match).
- Old `ResolveOutdoorCellIdTests` is gone (file renamed); the rewritten `ResolveCellIdTests` (3 tests) passes.

- [ ] **Step 8: Commit (the cellTransit + integration commit)**

```
git add src/AcDream.Core/Physics/CellTransit.cs `
        src/AcDream.Core/Physics/PhysicsEngine.cs `
        src/AcDream.Core/Physics/PhysicsDataCache.cs `
        src/AcDream.Core/Physics/TransitionTypes.cs `
        tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs `
        tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs `
        tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs `
        tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs

git rm tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs

git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — port CellTransit + delete AABB containment

New CellTransit static class ports retail's portal-graph cell traversal:
- FindTransitCellsSphere — indoor portal-neighbour walk
- AddAllOutsideCells     — outdoor 24m grid expansion
- FindCellList           — top-level driver (BFS through portals;
                           PointInsideCellBsp for final containment)

PhysicsEngine.ResolveOutdoorCellId renamed to ResolveCellId. Body
rewritten to delegate to CellTransit.FindCellList. Signature extended
with sphereRadius parameter (needed by the sphere-vs-portal-plane test).
Three call sites updated (PhysicsEngine ×2, TransitionTypes ×1).

Deletes PhysicsDataCache.TryFindContainingCell + the corresponding AABB
compute. The previous Phase D AABB-containment tests are dropped; new
tests under CellTransit*Tests cover the equivalent scenarios via portal
traversal.

Outdoor→indoor entry (check_building_transit) is wired but no-op until
the BuildingPhysics infrastructure lands in a follow-up commit.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Add `BuildingPhysics` + `CheckBuildingTransit` (TDD)

**Files:**
- Create: `src/AcDream.Core/Physics/BuildingPhysics.cs`
- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs`
- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- Create: `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs`

- [ ] **Step 1: Create the `BuildingPhysics` type**

Create `src/AcDream.Core/Physics/BuildingPhysics.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;

namespace AcDream.Core.Physics;

/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
/// (per the pseudocode doc §"LandCell.find_transit_cells").
/// </summary>
public sealed class BuildingPhysics
{
    public required Matrix4x4 WorldTransform { get; init; }
    public required Matrix4x4 InverseWorldTransform { get; init; }
    public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
}

/// <summary>
/// One building portal: the connection from a SortCell's BuildingObj to
/// an interior EnvCell.
/// </summary>
public readonly struct BldPortalInfo
{
    public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags, bool exactMatch)
    {
        OtherCellId   = otherCellId;
        OtherPortalId = otherPortalId;
        Flags         = flags;
        ExactMatch    = exactMatch;
    }

    /// <summary>Full id of the interior EnvCell this portal connects to.</summary>
    public uint   OtherCellId   { get; }
    /// <summary>The portal id within the destination EnvCell.</summary>
    public ushort OtherPortalId { get; }
    public ushort Flags         { get; }
    public bool   ExactMatch    { get; }
}
```

- [ ] **Step 2: Add `CacheBuilding` + `GetBuilding` to `PhysicsDataCache`**

In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, near the existing `CacheCellStruct` method (around line 180), add:

```csharp
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────

private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();

/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
{
    if (_buildings.ContainsKey(landcellId)) return;
    Matrix4x4.Invert(worldTransform, out var inverse);
    _buildings[landcellId] = new BuildingPhysics
    {
        WorldTransform = worldTransform,
        InverseWorldTransform = inverse,
        Portals = portals,
    };
}

public BuildingPhysics? GetBuilding(uint landcellId)
    => _buildings.TryGetValue(landcellId, out var b) ? b : null;

public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;

/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
```

- [ ] **Step 3: Write the failing test**

Create `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs`:

```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class CellTransitCheckBuildingTransitTests
{
    [Fact]
    public void SphereOverlapsBuildingPortal_AddsInteriorCell()
    {
        // Building at world origin. One portal to interior cell 0xA9B40100.
        var building = new BuildingPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Portals = new[]
            {
                new BldPortalInfo(
                    otherCellId: 0xA9B40100u,
                    otherPortalId: 0,
                    flags: 0,
                    exactMatch: false),
            },
        };

        // Interior cell whose CellBSP returns "inside" for the sphere center.
        // For this test we use a null CellBSP — PointInsideCellBsp on null
        // returns true, so the cell registers as containing the point.
        var interiorCell = new CellPhysics
        {
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
            Resolved = new Dictionary<ushort, ResolvedPolygon>(),
        };

        var cache = new PhysicsDataCache();
        cache.RegisterBuildingForTest(0xA9B40001u, building);
        cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);

        var candidates = new HashSet<uint>();
        CellTransit.CheckBuildingTransit(
            cache,
            building,
            worldSphereCenter: new Vector3(0, 0, 0),
            sphereRadius: 0.5f,
            candidates);

        Assert.Contains(0xA9B40100u, candidates);
    }
}
```

- [ ] **Step 4: Add `CheckBuildingTransit` to `CellTransit`**

Append to `src/AcDream.Core/Physics/CellTransit.cs`:

```csharp
    /// <summary>
    /// Outdoor→indoor entry path. Ported from retail's
    /// <c>BuildingObj::find_building_transit_cells</c> +
    /// <c>EnvCell::check_building_transit</c>. For each portal of the
    /// outdoor building, look up the destination interior cell and test
    /// whether the sphere overlaps it via <see cref="BSPQuery.PointInsideCellBsp"/>.
    /// If so, add the interior cell to <paramref name="candidates"/>.
    /// </summary>
    public static void CheckBuildingTransit(
        PhysicsDataCache cache,
        BuildingPhysics building,
        Vector3 worldSphereCenter,
        float sphereRadius,
        HashSet<uint> candidates)
    {
        foreach (var portal in building.Portals)
        {
            if (portal.OtherCellId == 0xFFFFFFFFu) continue;

            var otherCell = cache.GetCellStruct(portal.OtherCellId);
            if (otherCell is null) continue;

            // Sphere center in the OTHER cell's local space.
            var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);

            // Use PointInsideCellBsp if available; else fall through.
            if (otherCell.CellBSP?.Root is null) continue;
            if (BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter))
            {
                candidates.Add(portal.OtherCellId);
            }
        }
    }
```

- [ ] **Step 5: Wire `CheckBuildingTransit` into `FindCellList`**

In `src/AcDream.Core/Physics/CellTransit.cs`, find the outdoor seed branch of `FindCellList`:

```csharp
else
{
    // Outdoor seed.
    AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
    // TODO: check_building_transit hookup at Task 7.
}
```

Replace the TODO with the building loop:

```csharp
else
{
    // Outdoor seed.
    AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);

    // For each landcell candidate, check if it has a building stab.
    var landcellSnapshot = new List<uint>(candidates);
    foreach (uint landcellId in landcellSnapshot)
    {
        var building = cache.GetBuilding(landcellId);
        if (building is null) continue;
        CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
    }
}
```

- [ ] **Step 6: Wire `CacheBuilding` at landblock load**

In `src/AcDream.App/Rendering/GameWindow.cs`, find the existing `lbInfo.Buildings` iteration (around line 5641 where portal planes are extracted). Add a call to `_physicsDataCache.CacheBuilding` inside the loop, using the actual property names from Task 0. Example shape (the exact property names need substitution):

```csharp
foreach (var building in lbInfo.Buildings)
{
    // building.Portals → IReadOnlyList<BldPortal>
    // building.Frame   → world transform (or building.Transform — check Task 0)
    var portals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(building.Portals.Count);
    foreach (var bp in building.Portals)
    {
        portals.Add(new AcDream.Core.Physics.BldPortalInfo(
            otherCellId:   bp.OtherCellId,
            otherPortalId: bp.OtherPortalId,
            flags:         (ushort)bp.Flags,
            exactMatch:    bp.ExactMatch));
    }

    // Compute the building's world transform.
    var buildingTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
                          * System.Numerics.Matrix4x4.CreateTranslation(building.Frame.Origin + origin);

    // Building lives in a specific outdoor landcell. The dat encoding is
    // typically a "SortCell" id — derive it from building.Frame.Origin or
    // the building's containing-cell field (verify in Task 0).
    uint landcellId = (lb.LandblockId & 0xFFFF0000u)
                    | (uint)(((int)(building.Frame.Origin.X / 24f) * 8 + (int)(building.Frame.Origin.Y / 24f)) + 1);

    _physicsDataCache.CacheBuilding(landcellId, portals, buildingTransform);
}
```

(The landcell-id derivation may need adjusting based on retail's exact SortCell rules — confirm during Task 0 investigation. If the building's containing-cell is stored explicitly in the DAT, use that instead of the X/Y math.)

- [ ] **Step 7: Run all tests**

Run: `dotnet build && dotnet test`
Expected: build green, all new tests pass, pre-existing 8 failures unchanged.

- [ ] **Step 8: Commit**

```
git add src/AcDream.Core/Physics/BuildingPhysics.cs `
        src/AcDream.Core/Physics/PhysicsDataCache.cs `
        src/AcDream.Core/Physics/CellTransit.cs `
        src/AcDream.App/Rendering/GameWindow.cs `
        tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs

git commit -m "$(cat <<'EOF'
feat(physics): Phase 2 — port BuildingPhysics + CheckBuildingTransit

Adds outdoor→indoor cell entry via building portals. Ported from
retail's BuildingObj::find_building_transit_cells +
CEnvCell::check_building_transit.

New BuildingPhysics type holds the per-SortCell BldPortal list +
building world transform. CacheBuilding wires from GameWindow at
landblock load. CellTransit.FindCellList's outdoor branch now expands
each landcell candidate's building portals (if any) via
CheckBuildingTransit, which point-in-cell tests the destination
interior cell. Closes the "walk into a building from outside" path.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Capture session (user-driven)

**Goal:** verify the four acceptance criteria from the spec live in the Holtburg cottage.

- [ ] **Step 1: Build green**

Run: `dotnet build`
Expected: 0 errors.

- [ ] **Step 2: Launch the client**

User runs:

```powershell
$env:ACDREAM_DAT_DIR              = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE                 = "1"
$env:ACDREAM_TEST_HOST            = "127.0.0.1"
$env:ACDREAM_TEST_PORT            = "9000"
$env:ACDREAM_TEST_USER            = "testaccount"
$env:ACDREAM_TEST_PASS            = "testpassword"
$env:ACDREAM_PROBE_INDOOR_BSP     = "1"
$env:ACDREAM_PROBE_CELL           = "1"
$env:ACDREAM_PROBE_CELL_CACHE     = "1"
$env:ACDREAM_DEVTOOLS             = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 |
    Tee-Object -FilePath "launch-phase2-verify.log"
```

- [ ] **Step 3: Test the four acceptance scenarios**

1. **Indoor walking** — Enter the Holtburg cottage. Walk around freely. Walls must block from inside. Furniture must still collide.
2. **Outdoor→indoor** — Walk toward the cottage door from outside. The door must let you through; the walls beside the door must block.
3. **Indoor→outdoor** — Walk back out through the door. Outdoor terrain collision must resume; you're no longer trapped inside.
4. **Indoor→indoor** — If the cottage has multiple rooms, walk between them.

Close the window when done.

- [ ] **Step 4: Verify the log shows clean transitions**

Run:
```bash
rg -c "^\[cell-transit\]" launch-phase2-verify.log
rg -c "^\[indoor-bsp\].*result=Collided" launch-phase2-verify.log
```

Expected:
- Multiple `[cell-transit]` lines including indoor↔outdoor crossings with proper landblock prefixes.
- `[indoor-bsp] result=Collided` lines firing when the player walks into walls.

If neither shows up: portal traversal isn't firing. Re-launch with debugging or escalate.

- [ ] **Step 5: Save log artifact**

The log at `launch-phase2-verify.log` will be cited in the Phase F handoff doc. No commit needed yet.

---

## Task 10: Docs cleanup + handoff

**Files:**
- Modify: `docs/ISSUES.md`
- Modify: `docs/plans/2026-04-11-roadmap.md`
- Modify: `CLAUDE.md`
- Create: `docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md`

- [ ] **Step 1: Close issues**

In `docs/ISSUES.md`:

- Move **#87** (Indoor cell tracking uses AABB containment...) to "Recently closed". Status DONE. Closed date + commit SHAs.
- Update **#84** (blocked by air indoors): the remaining wall-pass-through symptom is closed by Phase 2. Move to "Recently closed".
- Update **#85** (pass through walls outside→in): the outdoor→indoor entry via `BuildingObj` portals closes this. Move to "Recently closed".

- [ ] **Step 2: Update roadmap**

In `docs/plans/2026-04-11-roadmap.md`, add a "Recently shipped" row for "Indoor portal-based cell tracking" with date + commit SHAs. Remove any forward entry for this work.

- [ ] **Step 3: Update CLAUDE.md**

Update the "Currently in Phase..." paragraph to reflect Phase 2 shipped. Next phase is Claude's choice per work-order autonomy.

- [ ] **Step 4: Write the shipped-handoff doc**

Create `docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md`. Mirror the format of `docs/research/2026-05-19-cluster-a-shipped-handoff.md`. Cover:

- Commits list with SHAs
- One-paragraph summary of what shipped
- Per-issue resolution (#87, #84, #85 all closed)
- Probe evidence from `launch-phase2-verify.log`
- Diagnostic infrastructure that persists (`[cell-cache]`, `[indoor-bsp]`)
- Follow-up items: `BSPQuery.SphereIntersectsCellBsp` for retail-faithful neighbour-add; parts/AABB variant of `find_transit_cells` for remote entities; `VisibleCells` cleanup filter.

- [ ] **Step 5: Final build + test sweep**

Run: `dotnet build && dotnet test`
Expected: 0 errors, all new tests pass, 8 pre-existing failures unchanged.

- [ ] **Step 6: Commit the docs**

```
git add docs/ISSUES.md `
        docs/plans/2026-04-11-roadmap.md `
        CLAUDE.md `
        docs/research/<ship-date>-portal-cell-tracking-shipped-handoff.md

git commit -m "$(cat <<'EOF'
docs(phase): Indoor portal-based cell tracking shipped

Closes ISSUES.md #87, #84, #85. Portal-graph cell traversal replaces
Phase D's AABB containment; player can now walk freely inside
buildings, walls block consistently, doors update CellId correctly,
walking into a building from outside works.

ISSUES.md: #84/#85/#87 → Recently closed.
Roadmap: Indoor portal cell tracking added to shipped table.
CLAUDE.md: current-phase paragraph updated.

Follow-up items (filed inline in handoff doc):
- BSPQuery.SphereIntersectsCellBsp port for retail-faithful neighbour-add
  (currently uses a plane-side heuristic)
- parts/AABB variant of find_transit_cells for remote-entity cell tracking
- VisibleCells cleanup filter at end of find_cell_list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Self-review checklist (run after writing the plan)

1. **Spec coverage:** every spec section maps to a task. ✓ §1-2 → background; §3-4 architecture/components → Tasks 1-7; §5 data flow → Tasks 3 + 7; §6 commit shape → Tasks 3 + 7 + 8 + 10; §7 files → File Structure table; §9 testing → per-task unit tests + Task 9 live; §10 acceptance → Task 9.
2. **Placeholder scan:** Two intentional `<CellBSP-property-name>` substitutions remain in Task 3 — these get resolved at Task 0. The landcell-id derivation in Task 8 Step 6 may need tweaking based on Task 0 findings — flagged inline. No "TBD", "TODO", or unspecified behavior.
3. **Type consistency:**
   - `PortalInfo(ushort, ushort, ushort)` is consistent across Tasks 1, 2, 3, 4.
   - `BldPortalInfo(uint, ushort, ushort, bool)` consistent across Tasks 8.
   - `CellTransit.FindCellList(cache, worldSphereCenter, sphereRadius, currentCellId) → uint` consistent across Tasks 6, 7.
   - `CellTransit.FindTransitCellsSphere(cache, currentCell, currentCellId, ws, r, candidates, out exitOutside)` consistent across Tasks 4, 6.
   - `CellTransit.AddAllOutsideCells(ws, r, currentCellId, candidates)` consistent across Tasks 5, 6, 7.
   - `CellTransit.CheckBuildingTransit(cache, building, ws, r, candidates)` consistent across Task 8.
4. **Acceptance:** matches spec §10. Visual verification by user in Task 9.
