# 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.