diff --git a/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md new file mode 100644 index 0000000..6b5d446 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md @@ -0,0 +1,1846 @@ +# 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` 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, 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.` 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 `` 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; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal connection between two +/// EnvCells. Each carries a list of these, +/// mirroring retail's CCellStruct.portals array. +/// +/// +/// is a low-16 cell index (combined with the +/// owning landblock prefix at lookup time) or 0xFFFF to mean +/// "exit to outdoor world" (the player crosses this portal to leave +/// the building). +/// +/// +/// +/// indexes the OWNING cell's +/// dict (the visible-polygon +/// table, NOT which holds physics +/// polys). +/// +/// +/// +/// decodes bit 2 of : +/// (Flags & 2) == 0 → portal's polygon normal points INTO +/// the owning cell (so dist > 0 in cell-local space means "outside +/// the cell, beyond the portal"). Used in find_transit_cells's +/// load-hint path for unloaded neighbours. +/// +/// +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; } + + /// Bit 2 of . See struct docstring. + 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`, init-only, default empty). +3. ADD: `PortalPolygons` (`Dictionary?`, init-only, default null). +4. ADD: `VisibleCellIds` (`IReadOnlySet`, 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? PhysicsPolygons { get; init; } + public VertexArray? Vertices { get; init; } + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required Dictionary Resolved { get; init; } + + // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── + + /// + /// The cell BSP used for + /// (point-in-cell tests). Separate tree from + /// (collision) and from the renderer's drawing-BSP. + /// Source: cellStruct.<CellBSP-property-name> at cache time. + /// Nullable: cells without a CellBSP cannot participate in portal + /// containment and are skipped by . + /// + public PhysicsBSPTree? CellBSP { get; init; } + + /// + /// Portal connections to neighbouring cells, in cell-local space. + /// Default: empty list. Source: envCell.CellPortals. + /// + public IReadOnlyList Portals { get; init; } = System.Array.Empty(); + + /// + /// Resolved VISIBLE polygons (from cellStruct.Polygons), + /// keyed by polygon id. Distinct from which + /// holds PhysicsPolygons. Portal lookup via + /// resolves through this dict. + /// Nullable when the cell has no visible polys (rare). + /// + public Dictionary? PortalPolygons { get; init; } + + /// + /// The cell ids visible from this cell (low-16 indexes, combined + /// with the owning landblock prefix). Populated from + /// envCell.VisibleCells. Unused this phase; reserved for the + /// optional find_cell_list visibility filter. + /// + public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); +} +``` + +**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(), + }; + + 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(), + Portals = new[] { portal }, + VisibleCellIds = new System.Collections.Generic.HashSet { 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 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(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(); + 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., // ← 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(); + 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.?.Root is null ? "null" : "ok")} " + + $"worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})")); + } +} +``` + +**Substitution:** `` 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(), + 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) +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(), + PortalPolygons = new Dictionary { [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(), + // 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(); + 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(); + 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(); + + 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; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, +/// ported from retail's CObjCell::find_cell_list family +/// (sphere variant for the player's single foot sphere). +/// +/// +/// Replaces Phase D's AABB containment. Uses the cell BSP for retail- +/// faithful point-in-cell tests via +/// . Walks the portal graph +/// starting from a given current cell to find which cells a moving +/// sphere overlaps. +/// +/// +/// +/// Reference pseudocode: +/// docs/research/acclient_indoor_transitions_pseudocode.md +/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells +/// (sphere variant) at acclient_2013_pseudo_c.txt. +/// +/// +public static class CellTransit +{ + /// + /// Small radius padding matching retail's EPSILON usage in the + /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells"). + /// + private const float EPSILON = 0.02f; + + /// + /// Indoor portal-neighbour expansion. For each portal of + /// , test whether the sphere overlaps + /// the portal polygon's plane in cell-local space. If so, add the + /// neighbour cell to . + /// + /// + /// Ported from CEnvCell::find_transit_cells (sphere variant) + /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)". + /// + /// + /// The physics data cache (for neighbour lookups). + /// The cell whose portals are walked. + /// The full id (with landblock prefix) of + /// . Used to resolve neighbour ids by + /// combining the prefix with . + /// Player's foot-sphere center in world space. + /// Player's foot-sphere radius. + /// Set to add neighbour cell ids to. + /// Set to true if the sphere straddles an + /// exit portal (OtherCellId == 0xFFFF) — the caller should + /// then expand outdoor neighbour cells via . + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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(); + 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(); + 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 + /// + /// Outdoor neighbour expansion. Ported from + /// CLandCell::add_all_outside_cells (sphere variant) per the + /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". + /// + /// + /// 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. + /// + /// + public static void AddAllOutsideCells( + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + HashSet 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 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(), + }; + 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 + /// + /// Top-level cell-tracking driver, ported from retail's + /// CObjCell::find_cell_list (sphere variant). + /// + /// + /// Walks the portal graph from , + /// finds the cell whose contains + /// the sphere center, and returns its full id (landblock-prefixed). + /// Falls back to when no candidate + /// matches. + /// + /// + /// + /// Pseudocode reference: + /// docs/research/acclient_indoor_transitions_pseudocode.md + /// §"Overall Driver: find_cell_list". + /// + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId) + { + var candidates = new HashSet(); + 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(); + 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 +/// +/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a +/// given world position via retail's portal-graph traversal. Delegates +/// to . +/// +/// +/// Replaces Phase D's ResolveOutdoorCellId which used AABB +/// containment — see docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md +/// for the design. +/// +/// +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) +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; + +/// +/// 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 BuildingObj.Portals array +/// (per the pseudocode doc §"LandCell.find_transit_cells"). +/// +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required IReadOnlyList Portals { get; init; } +} + +/// +/// One building portal: the connection from a SortCell's BuildingObj to +/// an interior EnvCell. +/// +public readonly struct BldPortalInfo +{ + public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags, bool exactMatch) + { + OtherCellId = otherCellId; + OtherPortalId = otherPortalId; + Flags = flags; + ExactMatch = exactMatch; + } + + /// Full id of the interior EnvCell this portal connects to. + public uint OtherCellId { get; } + /// The portal id within the destination EnvCell. + 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 _buildings = new(); + +/// +/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list +/// for an outdoor landcell that contains a building stab. Used by +/// . +/// +public void CacheBuilding(uint landcellId, IReadOnlyList 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 BuildingIds => (IReadOnlyCollection)_buildings.Keys; + +/// Test helper, mirrors . +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(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterBuildingForTest(0xA9B40001u, building); + cache.RegisterCellStructForTest(0xA9B40100u, interiorCell); + + var candidates = new HashSet(); + 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 + /// + /// Outdoor→indoor entry path. Ported from retail's + /// BuildingObj::find_building_transit_cells + + /// EnvCell::check_building_transit. For each portal of the + /// outdoor building, look up the destination interior cell and test + /// whether the sphere overlaps it via . + /// If so, add the interior cell to . + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics building, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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(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 + // building.Frame → world transform (or building.Transform — check Task 0) + var portals = new System.Collections.Generic.List(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) +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/-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/-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/-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) +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 `` 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.