diff --git a/docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md b/docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md new file mode 100644 index 0000000..51efdef --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md @@ -0,0 +1,1156 @@ +# Phase A4 — Multi-cell BSP Iteration 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:** Port retail's `CTransition::check_other_cells` so `Transition.FindEnvCollisions` queries every cell the foot-sphere geometrically overlaps, not just the one cell the player's center sits in. Closes the Holtburg inn vestibule wall walk-through (cell `0xA9B40164`). + +**Architecture:** Three pieces. (1) `CellTransit.FindCellSet` — new overload returning the full candidate-cell `HashSet` that `FindCellList` currently discards. (2) `Transition.CheckOtherCells` — direct port of retail's per-cell loop with Collided/Adjusted halt, Slid + CP-clear halt, OK continue. (3) Wire-up in `FindEnvCollisions` between the primary cell's BSP return and the synthesis fall-through. + +**Tech Stack:** C# .NET 10, xUnit test framework, `Silk.NET.OpenGL`. Existing `PhysicsBSPNode` + `BSPQuery.FindCollisions` 6-path dispatcher; existing `PhysicsDataCache.RegisterCellStructForTest` test seam. + +**Spec:** [docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md](../specs/2026-05-20-phase-a4-multi-cell-bsp-design.md) + +**Retail oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt:272717-272798` (`CTransition::check_other_cells`). + +--- + +## Task 0: Pre-flight — verify baseline build + tests + +**Files:** (none modified) + +- [ ] **Step 1: Run baseline build** + +``` +dotnet build -c Debug +``` + +Expected: `Build succeeded. 0 Error(s).` + +- [ ] **Step 2: Run baseline test suite** + +``` +dotnet test -c Debug --nologo --verbosity minimal +``` + +Expected: ~1129 passing, 0 failing. If failures appear, STOP and investigate before touching any code — the spec's "1129-test baseline holds" claim is the foundation. + +--- + +## Task 1: `CellTransit.FindCellSet` overload (TDD) + +**Files:** +- Create: `tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs` +- Modify: `src/AcDream.Core/Physics/CellTransit.cs` (extract `FindCellList` body, add new overload) + +- [ ] **Step 1: Write failing tests** + +Create `tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitFindCellSetTests +{ + // ────────────────────────────────────────────────────────────────── + // Helpers — mirror CellTransitFindTransitCellsSphereTests.cs pattern + // ────────────────────────────────────────────────────────────────── + + private static CellPhysics MakeCellWithPortalAtRightWall( + Matrix4x4 worldTransform, uint otherCellId, ushort flags) + { + var portalPoly = 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 = CullMode.None, + }; + + Matrix4x4.Invert(worldTransform, out var inv); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + PortalPolygons = new Dictionary { [10] = portalPoly }, + Portals = new[] + { + new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags), + }, + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + } + } + }; + } + + // ────────────────────────────────────────────────────────────────── + // Tests + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void Sphere_FullyInsidePrimaryCell_ReturnsOnlyPrimary() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + + // Sphere far from any portal — local x=-1, reach to x=-0.5; portal at x=2.5. + var sphereCenter = new Vector3(-1.0f, 0f, 2.5f); + + uint containing = CellTransit.FindCellSet( + cache, sphereCenter, sphereRadius: 0.5f, + currentCellId: 0xA9B40100u, + out var cellSet); + + Assert.Equal(0xA9B40100u, containing); + Assert.Single(cellSet); + Assert.Contains(0xA9B40100u, cellSet); + } + + [Fact] + public void Sphere_StraddlingPortal_ReturnsBothCells() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + } + } + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Sphere center at local x=2.0, radius=0.5 → reaches x=2.5 = portal plane. + var sphereCenter = new Vector3(2.0f, 0f, 2.5f); + + uint containing = CellTransit.FindCellSet( + cache, sphereCenter, sphereRadius: 0.5f, + currentCellId: 0xA9B40100u, + out var cellSet); + + Assert.Contains(0xA9B40100u, cellSet); + Assert.Contains(0xA9B40101u, cellSet); + } + + [Fact] + public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells() + { + var cache = new PhysicsDataCache(); + // Outdoor seed near a cell boundary — expand to neighbours via + // AddAllOutsideCells. Landcells have no CellPhysics in cache, so + // they appear in the set but the containing-cell loop falls back + // to currentCellId. The point of this test: the SET captures + // them even though FindCellList's single-uint return cannot. + var sphereCenter = new Vector3(23.8f, 12f, 0f); // near east boundary of landcell at grid(0,0) + + uint containing = CellTransit.FindCellSet( + cache, sphereCenter, sphereRadius: 0.5f, + currentCellId: 0xA9B40001u, // outdoor cell, low byte < 0x100 + out var cellSet); + + Assert.Equal(0xA9B40001u, containing); + Assert.True(cellSet.Count >= 2, $"Expected ≥2 cells in set (primary + east neighbour), got {cellSet.Count}"); + } +} +``` + +- [ ] **Step 2: Verify tests fail (method does not exist)** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~CellTransitFindCellSetTests" --nologo +``` + +Expected: **3 tests fail** with compile error `CellTransit.FindCellSet does not exist`. + +- [ ] **Step 3: Implement `FindCellSet` by refactoring `FindCellList`** + +Edit `src/AcDream.Core/Physics/CellTransit.cs`. Find the existing `FindCellList` method (starts at line 235). Extract its body into a private helper `BuildCellSetAndPickContaining` that returns both the containing cell id AND the candidate `HashSet`, then make `FindCellList` a thin wrapper and add the new `FindCellSet` overload. + +Replace the existing `FindCellList` method (lines 235 to end-of-method, including its XML doc) with: + +```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. The candidate set built internally is discarded; use + /// to recover it. + /// + /// + /// + /// 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) + { + return BuildCellSetAndPickContaining( + cache, worldSphereCenter, sphereRadius, currentCellId, + out _); + } + + /// + /// Phase A4 (2026-05-20). Same portal-graph traversal as + /// but additionally returns the full + /// candidate set built during traversal. Used by + /// to iterate every cell + /// the sphere overlaps for per-cell BSP collision. + /// + /// + /// Retail oracle: CTransition::check_other_cells at + /// acclient_2013_pseudo_c.txt:272717-272798 calls + /// CObjCell::find_cell_list(&this->cell_array, &var_4c, ...) + /// which fills both the cell_array (set) and var_4c (containing cell). + /// + /// + public static uint FindCellSet( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + out IReadOnlyCollection cellSet) + { + var containing = BuildCellSetAndPickContaining( + cache, worldSphereCenter, sphereRadius, currentCellId, + out var candidates); + cellSet = candidates; + return containing; + } + + private static uint BuildCellSetAndPickContaining( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + out HashSet candidates) + { + 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); + + var pending = new Queue(); + var visited = new HashSet(); + pending.Enqueue(currentCellId); + visited.Add(currentCellId); + int maxIterations = 16; + 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); + + if (candidates.Count > sizeBefore) + { + foreach (var c in candidates) + { + if (visited.Add(c)) + pending.Enqueue(c); + } + } + + if (exitOutside) + { + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + } + } + } + else + { + // Outdoor seed. + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + + 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); + } + } + + // Containment test. + 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; + } + + return currentCellId; + } +``` + +- [ ] **Step 4: Verify tests pass** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~CellTransitFindCellSetTests" --nologo +``` + +Expected: **3 passing, 0 failing.** + +- [ ] **Step 5: Run full physics suite to confirm no regressions** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~AcDream.Core.Tests.Physics" --nologo +``` + +Expected: All physics tests pass, including the 4 existing `CellTransit*` test classes (FindCellList, AddAllOutsideCells, CheckBuildingTransit, FindTransitCellsSphere). The refactor is behavior-preserving for `FindCellList` callers. + +- [ ] **Step 6: Commit** + +``` +git add src/AcDream.Core/Physics/CellTransit.cs tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +git commit -m "$(cat <<'EOF' +feat(physics): A4 — CellTransit.FindCellSet overload exposes candidate set + +Refactors FindCellList to delegate to a private helper that returns BOTH +the containing cell id AND the full candidate HashSet. Public surface +gains a new FindCellSet overload; existing FindCellList behavior is +unchanged. + +Used by the upcoming Transition.CheckOtherCells (Phase A4) to iterate +every cell the sphere overlaps for per-cell BSP collision. Mirrors +retail's CObjCell::find_cell_list filling both cell_array AND var_4c +at acclient_2013_pseudo_c.txt:272725. + +Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: `Transition.CheckOtherCells` + helper (TDD) + +**Files:** +- Create: `tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs` +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (add `CheckOtherCells` + `ApplyOtherCellResult` methods) + +- [ ] **Step 1: Write failing tests** + +Create `tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for the result-combine helper used by +/// . The iteration / per-cell +/// BSP-query parts are covered end-to-end by +/// ; this file pins the +/// retail-faithful halt semantics that +/// acclient_2013_pseudo_c.txt:272739-272752 spells out. +/// +public class TransitionCheckOtherCellsTests +{ + private static Transition MakeTransition(bool contactFlag = false) + { + var t = new Transition(); + t.SpherePath.InitPath(Vector3.Zero, Vector3.Zero, cellId: 0xA9B40100u, sphereRadius: 0.48f); + t.ObjectInfo.State = contactFlag ? ObjectInfoState.Contact : ObjectInfoState.None; + // Pre-set CP fields to non-default so the Slid-clears-CP assertion + // can detect the clear. + t.CollisionInfo.ContactPlaneValid = true; + t.CollisionInfo.ContactPlaneIsWater = true; + return t; + } + + [Fact] + public void OK_ContinuesIteration_DoesNotMutate() + { + var t = MakeTransition(); + + bool halt = t.ApplyOtherCellResult(TransitionState.OK, out var finalState); + + Assert.False(halt); + Assert.Equal(TransitionState.OK, finalState); + Assert.True(t.CollisionInfo.ContactPlaneValid); + Assert.True(t.CollisionInfo.ContactPlaneIsWater); + Assert.False(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Collided_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact() + { + var t = MakeTransition(contactFlag: false); + + bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Collided, finalState); + Assert.True(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Collided_DoesNotSetCollidedWithEnvironment_WhenInContact() + { + // Retail oracle gating: the CollidedWithEnvironment flip mirrors + // the existing primary-cell behavior in FindEnvCollisions — + // skipped when ObjectInfo.State has Contact bit set. + var t = MakeTransition(contactFlag: true); + + bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Collided, finalState); + Assert.False(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Adjusted_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact() + { + var t = MakeTransition(contactFlag: false); + + bool halt = t.ApplyOtherCellResult(TransitionState.Adjusted, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Adjusted, finalState); + Assert.True(t.CollisionInfo.CollidedWithEnvironment); + } + + [Fact] + public void Slid_HaltsAndClearsContactPlaneFields() + { + // Retail oracle: acclient_2013_pseudo_c.txt:272746-272750 + // case 4: + // this->collision_info.contact_plane_valid = 0; + // this->collision_info.contact_plane_is_water = 0; + // return result; + var t = MakeTransition(); + Assert.True(t.CollisionInfo.ContactPlaneValid); // pre-condition + Assert.True(t.CollisionInfo.ContactPlaneIsWater); // pre-condition + + bool halt = t.ApplyOtherCellResult(TransitionState.Slid, out var finalState); + + Assert.True(halt); + Assert.Equal(TransitionState.Slid, finalState); + Assert.False(t.CollisionInfo.ContactPlaneValid); + Assert.False(t.CollisionInfo.ContactPlaneIsWater); + } + + [Fact] + public void CheckOtherCells_CellWithNullBspRoot_IsSkippedNoCrash() + { + // Iteration safety: a CellPhysics in the candidate set with + // `BSP = null` (loaded for render but not physics) must be skipped, + // not crash. Matches the spec's R2 guard at design §Edge cases E2. + var cell = new CellPhysics + { + BSP = null, // <-- the guard target + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + + var engine = new PhysicsEngine(); + // FindEnvCollisions has terrain probes downstream; populate a + // minimal landblock so the cache + engine are coherent. The cell + // we test against doesn't need a real landblock entry. + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + var ht = new float[256]; + for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; + engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht), + Array.Empty(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + engine.DataCache.RegisterCellStructForTest(0xA9B40157u, cell); + + var t = MakeTransition(); + var cellSet = new HashSet { 0xA9B40157u }; + + // Call CheckOtherCells directly via the internal seam. + var result = t.CheckOtherCells(engine, Vector3.Zero, 0.48f, cellSet); + + Assert.Equal(TransitionState.OK, result); + } +} +``` + +- [ ] **Step 2: Verify tests fail (method does not exist)** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~TransitionCheckOtherCellsTests" --nologo +``` + +Expected: **6 tests fail** with compile errors (`Transition.ApplyOtherCellResult does not exist`, `Transition.CheckOtherCells does not exist`). + +- [ ] **Step 3: Implement `CheckOtherCells` + `ApplyOtherCellResult`** + +Open `src/AcDream.Core/Physics/TransitionTypes.cs`. Locate `private TransitionState FindEnvCollisions(PhysicsEngine engine)` at line 1390. Insert these two methods immediately BEFORE that line (so they appear in the file alongside `FindEnvCollisions`): + +```csharp + /// + /// Phase A4 (2026-05-20). Port of retail's + /// CTransition::check_other_cells at + /// acclient_2013_pseudo_c.txt:272717-272798. + /// + /// + /// After the primary cell's BSP collision returns OK, iterate every + /// other cell in the sphere's overlap set and run BSP collision + /// against each. Halt on the first Collided/Adjusted/Slid; OK + /// continues. Mirrors retail's behaviour exactly — no save/restore + /// of state between cells. + /// + /// + internal TransitionState CheckOtherCells( + PhysicsEngine engine, + Vector3 footCenter, + float sphereRadius, + System.Collections.Generic.IReadOnlyCollection cellSet) + { + if (engine.DataCache is null) return TransitionState.OK; + var sp = SpherePath; + + // Deterministic order for greppable probe logs. Skip the primary + // cell — caller has already run its BSP. + var ordered = new System.Collections.Generic.List(cellSet); + ordered.Sort(); + + foreach (uint cellId in ordered) + { + if (cellId == sp.CheckCellId) continue; + + var cell = engine.DataCache.GetCellStruct(cellId); + // R2 guard: stale CellPhysics loaded for render but not physics. + if (cell?.BSP?.Root is null) continue; + + // Transform sphere into THIS cell's local space. Mirrors the + // primary-cell pattern at TransitionTypes.cs (FindEnvCollisions, + // ~line 1413) AND the Bug B world-origin fix that decomposes + // WorldTransform per cell so BSP Path-3 + Path-4 land write + // world-space ContactPlanes. + var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform); + var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = localCenter, + Radius = sphereRadius, + }; + DatReaderWriter.Types.Sphere? localSphere1 = null; + if (sp.NumSphere > 1) + { + localSphere1 = new DatReaderWriter.Types.Sphere + { + Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform), + Radius = sp.GlobalSphere[1].Radius, + }; + } + + System.Numerics.Quaternion cellRotation; + Vector3 cellOrigin; + if (!System.Numerics.Matrix4x4.Decompose(cell.WorldTransform, out _, + out cellRotation, out cellOrigin)) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] WARN cell 0x{cellId:X8} WorldTransform did not decompose — falling back to identity rotation")); + cellRotation = System.Numerics.Quaternion.Identity; + cellOrigin = cell.WorldTransform.Translation; + } + + var result = BSPQuery.FindCollisions( + cell.BSP.Root, cell.Resolved, this, + localSphere, localSphere1, localCurrCenter, + Vector3.UnitZ, 1.0f, cellRotation, engine, + worldOrigin: cellOrigin); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + Console.WriteLine(System.FormattableString.Invariant( + $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} result={result}")); + } + + if (ApplyOtherCellResult(result, out var halted)) + return halted; + } + + return TransitionState.OK; + } + + /// + /// Phase A4 (2026-05-20). Combine helper for + /// . Mirrors retail's switch at + /// acclient_2013_pseudo_c.txt:272739-272752: + /// Collided/Adjusted halt with CollidedWithEnvironment; Slid + /// halts AND clears the contact-plane fields; OK continues. + /// + internal bool ApplyOtherCellResult(TransitionState result, out TransitionState finalState) + { + finalState = result; + switch (result) + { + case TransitionState.Collided: + case TransitionState.Adjusted: + if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + CollisionInfo.CollidedWithEnvironment = true; + return true; + case TransitionState.Slid: + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + return true; + default: + return false; + } + } +``` + +- [ ] **Step 4: Verify tests pass** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~TransitionCheckOtherCellsTests" --nologo +``` + +Expected: **6 passing, 0 failing.** + +- [ ] **Step 5: Run full physics suite** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~AcDream.Core.Tests.Physics" --nologo +``` + +Expected: all physics tests pass. `CheckOtherCells` is not yet called from production code paths (only the new unit test invokes it directly via the internal seam); no behavior change to production paths. + +- [ ] **Step 6: Commit** + +``` +git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs +git commit -m "$(cat <<'EOF' +feat(physics): A4 — Transition.CheckOtherCells + ApplyOtherCellResult + +Port of retail's CTransition::check_other_cells at +acclient_2013_pseudo_c.txt:272717-272798. Iterates every non-primary +cell in a candidate set, runs BSPQuery.FindCollisions per cell with +that cell's WorldTransform-derived rotation + origin, halts on first +Collided/Adjusted/Slid. + +ApplyOtherCellResult is the combine-semantics helper extracted for +unit testability — it pins the retail switch: + - Collided/Adjusted → CollidedWithEnvironment = true (gated on + !Contact), halt. + - Slid → ContactPlaneValid + ContactPlaneIsWater = false, + halt. + - OK → continue. + +Not yet wired into FindEnvCollisions — see next commit. Probe gated +on PhysicsDiagnostics.ProbeIndoorBspEnabled (ACDREAM_PROBE_INDOOR_BSP). + +Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Wire `CheckOtherCells` into `FindEnvCollisions` (TDD) + +**Files:** +- Create: `tests/AcDream.Core.Tests/Physics/FindEnvCollisionsMultiCellTests.cs` +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (insert call in `FindEnvCollisions`) + +- [ ] **Step 1: Write failing integration test** + +Create `tests/AcDream.Core.Tests/Physics/FindEnvCollisionsMultiCellTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// End-to-end test that the indoor branch of +/// queries the cells the +/// sphere overlaps, not just the cell whose CellBSP contains the +/// sphere center. This is the core Phase A4 behaviour test — the +/// Holtburg inn vestibule (cell 0xA9B40164) bug reduced to a minimal +/// synthetic fixture. +/// +public class FindEnvCollisionsMultiCellTests +{ + // Indoor cell IDs — both have low-byte ≥ 0x100 to trigger the + // indoor branch of FindEnvCollisions. + private const uint VestibuleCellId = 0xA9B40164u; + private const uint InteriorCellId = 0xA9B40157u; + + private static PhysicsBSPTree EmptyLeafBsp() => new PhysicsBSPTree + { + Root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + } + }; + + private static (PhysicsBSPTree Bsp, Dictionary Resolved) + WallBspAtLocalX(float wallX) + { + // Single vertical wall poly facing -X (so a sphere advancing + // in +X collides with the wall surface). + var verts = new[] + { + new Vector3(wallX, -5f, 0f), + new Vector3(wallX, -5f, 5f), + new Vector3(wallX, 5f, 5f), + new Vector3(wallX, 5f, 0f), + }; + var normal = new Vector3(-1f, 0f, 0f); + float D = -Vector3.Dot(normal, verts[0]); + + var wallPoly = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, D), + NumPoints = 4, + SidesType = CullMode.None, + }; + const ushort wallId = 100; + var resolved = new Dictionary { [wallId] = wallPoly }; + + var bsp = new PhysicsBSPTree + { + Root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f }, + Polygons = new List { wallId }, + } + }; + return (bsp, resolved); + } + + private static CellBSPTree CellBspContainingOrigin() => new CellBSPTree + { + Root = new CellBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + } + }; + + [Fact] + public void IndoorSphereOverlappingAdjacentCellWithWall_ReturnsCollided() + { + // Vestibule cell (primary): empty BSP — no walls. CellBSP contains + // a portal at local x = +2.5 leading to the interior cell. + var portalPoly = 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(1f, 0f, 0f), -2.5f), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var vestibule = new CellPhysics + { + BSP = EmptyLeafBsp(), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = CellBspContainingOrigin(), + PortalPolygons = new Dictionary { [10] = portalPoly }, + Portals = new[] + { + new PortalInfo(otherCellId: (ushort)(InteriorCellId & 0xFFFFu), + polygonId: 10, flags: 0), + }, + }; + + // Interior cell: wall at local x=0 (which is global x=2.5 after + // the CreateTranslation(2.5, 0, 0) below — i.e. just inside the + // portal from the vestibule's perspective). + var (wallBsp, wallResolved) = WallBspAtLocalX(0f); + + var interiorWT = Matrix4x4.CreateTranslation(new Vector3(2.5f, 0f, 0f)); + Matrix4x4.Invert(interiorWT, out var interiorInv); + + var interior = new CellPhysics + { + BSP = wallBsp, + WorldTransform = interiorWT, + InverseWorldTransform = interiorInv, + Resolved = wallResolved, + CellBSP = CellBspContainingOrigin(), + }; + + // Engine + cache + landblock terrain (FindEnvCollisions's outdoor + // fall-through samples terrain — provide a flat strip so it + // doesn't NRE). + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + var hT = new float[256]; + for (int i = 0; i < 256; i++) hT[i] = i * 1.0f; + engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, hT), + Array.Empty(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + engine.DataCache.RegisterCellStructForTest(VestibuleCellId, vestibule); + engine.DataCache.RegisterCellStructForTest(InteriorCellId, interior); + + // Sphere in vestibule, foot near portal: world x=2.1, radius 0.48 + // → reach to x≈2.58, just past the portal at x=2.5. The vestibule + // BSP is empty (no walls), so without A4 this returns OK. With A4, + // the interior cell's wall at x=2.5 (global) must register Collided. + var from = new Vector3(2.0f, 0f, 0.2f); + var to = new Vector3(2.1f, 0f, 0.2f); + var transition = new Transition(); + transition.SpherePath.InitPath(from, to, VestibuleCellId, sphereRadius: 0.48f); + + // Act + bool ok = transition.FindTransitionalPosition(engine); + + // Assert: collision was detected (CollidedWithEnvironment was set + // by the interior cell's wall). + Assert.True(transition.CollisionInfo.CollidedWithEnvironment, + "Expected the interior cell's wall to halt the transition. Without A4 the empty vestibule BSP returns OK and the player walks through."); + _ = ok; // FindTransitionalPosition's bool return is not the assertion here. + } +} +``` + +- [ ] **Step 2: Verify test fails (no wire-up yet)** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~FindEnvCollisionsMultiCellTests" --nologo +``` + +Expected: **1 test fails** because `CheckOtherCells` is not yet called from `FindEnvCollisions`. Assertion failure: `CollidedWithEnvironment` is `false`. + +- [ ] **Step 3: Wire `CheckOtherCells` into `FindEnvCollisions`** + +Open `src/AcDream.Core/Physics/TransitionTypes.cs`. Locate the existing block in `FindEnvCollisions` around line 1499-1505 that returns when the primary cell's BSP gives a non-OK result: + +```csharp + if (cellState != TransitionState.OK) + { + if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + ci.CollidedWithEnvironment = true; + return cellState; + } +``` + +Immediately AFTER that block (so the new code runs only when the primary cell returned OK), insert: + +```csharp + // ── Phase A4 (2026-05-20): query every other cell ────────── + // Retail oracle: CTransition::check_other_cells at + // acclient_2013_pseudo_c.txt:272717-272798. The vestibule + // walls bug (cell 0xA9B40164 has only 4 polys; adjacent + // 0xA9B40157 has the actual walls) closes here. + // + // Discard the containing-cell return — sp.CheckCellId is + // already authoritative for the primary cell we just queried. + _ = CellTransit.FindCellSet(engine.DataCache, footCenter, sphereRadius, + sp.CheckCellId, out var cellSet); + var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet); + if (otherCellsState != TransitionState.OK) + return otherCellsState; + // ────────────────────────────────────────────────────────── +``` + +- [ ] **Step 4: Verify integration test passes** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~FindEnvCollisionsMultiCellTests" --nologo +``` + +Expected: **1 passing, 0 failing.** + +- [ ] **Step 5: Run full physics suite — baseline must hold** + +``` +dotnet test -c Debug --filter "FullyQualifiedName~AcDream.Core.Tests.Physics" --nologo +``` + +Expected: all physics tests pass. Especially watch: +- `TransitionTests` — terrain collision (outdoor) must not regress. +- `IndoorWalkablePlaneTests` — synthesis fall-through still works. +- `BSPStepUpTests` — Path 5 step-up behaviour unchanged. +- `BSPQueryTests` — the indoor world-origin regression test from Bug B (commit `de8ffde`) must remain green. + +- [ ] **Step 6: Run the FULL test suite to confirm no cross-layer regressions** + +``` +dotnet test -c Debug --nologo --verbosity minimal +``` + +Expected: 1129 + 10 (the 3 + 6 + 1 new tests this slice adds) = ~1139 passing, 0 failing. If any non-physics test regresses, STOP — A4 should not affect anything outside the physics layer. + +- [ ] **Step 7: Commit** + +``` +git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/FindEnvCollisionsMultiCellTests.cs +git commit -m "$(cat <<'EOF' +feat(physics): A4 — wire CheckOtherCells into FindEnvCollisions + +After the primary cell's BSP returns OK, query every other cell the +foot-sphere overlaps via CellTransit.FindCellSet + Transition.CheckOtherCells. +Closes the Holtburg inn vestibule wall walk-through: the vestibule +(cell 0xA9B40164) has only 4 BSP polys; walls live in the adjacent +interior cell (0xA9B40157). Without A4 the adjacent cell's BSP was +never queried. + +The end-to-end test reduces the real Holtburg bug to a minimal +synthetic two-cell fixture: empty vestibule BSP + interior cell with +one wall poly at the cell boundary. Pre-A4: passes (walk-through). +Post-A4: collides (CollidedWithEnvironment = true). + +Retail oracle: acclient_2013_pseudo_c.txt:272717-272798 +(CTransition::check_other_cells). + +Spec: docs/superpowers/specs/2026-05-20-phase-a4-multi-cell-bsp-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Visual verification at Holtburg inn vestibule + +**Files:** (none modified) + +Per CLAUDE.md "Running the client against the live server" + the A4 spec's visual acceptance. + +- [ ] **Step 1: Confirm build is fresh** + +``` +dotnet build -c Debug +``` + +Expected: green. + +- [ ] **Step 2: Launch the client with light probes** + +```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_DEVTOOLS = "1" +$env:ACDREAM_PROBE_INDOOR_BSP = "1" +$env:ACDREAM_PROBE_CELL = "1" +$env:ACDREAM_PROBE_CELL_CACHE = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-a4.log" +``` + +Run in the background (`run_in_background: true`). Do NOT set `ACDREAM_PROBE_RESOLVE` — it lagged the client last session. + +- [ ] **Step 3: User walks the acceptance scenarios** + +Tell the user: + +> Build is up. Please: +> 1. Walk to the Holtburg inn front door, enter the vestibule (cell 0xA9B40164, the small entrance area). +> 2. Try to walk through walls in the adjacent room (cell 0xA9B40157). They should BLOCK now. +> 3. Walk up the inn stairs — riser walls should also block, not pass through. +> 4. Walk back outside the inn — no regression in "thin air" collision around the building (A1/A1.5/A1.6 still work). +> 5. Cross a doorway threshold — no falling-through-floor regression (Bug A still fixed). +> +> Close the window when done. + +- [ ] **Step 4: Read launch.log for `[other-cells]` lines** + +```powershell +Get-Content launch-a4.log -Encoding Unicode | Out-File launch-a4.utf8.log -Encoding utf8 +``` + +``` +grep '\[other-cells\]' launch-a4.utf8.log | head -50 +``` + +Expected at least one line of the shape: +``` +[other-cells] primary=0xA9B40164 iter=0xA9B40157 result=Collided +``` + +If the user reports walls still walk-through but NO `[other-cells]` lines fire near the vestibule, the issue is in cell-set enumeration — go back to Task 1's tests. If lines fire with `result=OK` everywhere, the issue is in BSP query correctness for the adjacent cell — investigate that cell's polygons in isolation. + +- [ ] **Step 5: Decide pass/fail** + +If user confirms all 5 acceptance scenarios pass → continue to Task 5. + +If 1 or 2 scenarios fail → investigate; small fix may be possible inline. + +If 3+ scenarios fail → per CLAUDE.md stop rule, write a handoff doc at `docs/research/2026-05-20-phase-a4-failed-handoff.md` and stop. Do NOT push for a fourth attempt. + +--- + +## Task 5: Roadmap + ISSUES + handoff update + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` (add Phase A4 to shipped table) +- Modify: `CLAUDE.md` (update the "Indoor walking Phase 2 — Portal-based cell tracking shipped 2026-05-19" section header to mention A4 shipping) +- Modify: `docs/research/2026-05-21-open-items-pickup-prompt.md` (mark A4 closed in the landscape table; bump stair-verification to "next") +- Optional: `docs/ISSUES.md` (close issue if one was filed for vestibule walls) + +- [ ] **Step 1: Add A4 to the roadmap shipped table** + +Open `docs/plans/2026-04-11-roadmap.md`. Find the existing "shipped" table or list. Add a new row at the top of the most recent group: + +```markdown +| 2026-05-20 | Phase A4 | Multi-cell BSP iteration. Ports retail CTransition::check_other_cells; FindEnvCollisions now queries every cell the foot-sphere overlaps. Closes Holtburg inn vestibule wall walk-through. | | +``` + +(Use the actual commit SHA from Task 3, Step 7 — get it via `git log -1 --format=%h`.) + +- [ ] **Step 2: Update CLAUDE.md roadmap discipline section** + +In `CLAUDE.md`, locate the "Indoor walking Phase 2 — Portal-based cell tracking shipped 2026-05-19" header. Add a new paragraph immediately after the Phase 2 commit list: + +```markdown +**Indoor walking Phase A4 — Multi-cell BSP iteration shipped 2026-05-20.** +Three commits: +- `` — CellTransit.FindCellSet overload exposes the candidate set +- `` — Transition.CheckOtherCells + ApplyOtherCellResult port +- `` — wire-up in FindEnvCollisions + +Closes the Holtburg inn vestibule wall walk-through. Visual-verified at +cell `0xA9B40164` vestibule (walls in adjacent `0xA9B40157` now block). +Stair walk-through at the inn [PASS / FAIL — fill in based on Task 4 +outcome]. Next collision items: A2 (PHSP inversion), A3 (synthesis +removal — now unblocked). +``` + +Replace `` / `` / `` with actual SHAs from `git log --oneline -5`. + +- [ ] **Step 3: Update the pickup prompt** + +Open `docs/research/2026-05-21-open-items-pickup-prompt.md`. In the "landscape at a glance" table at the top, change A4's row to indicate `CLOSED 2026-05-20`. Move the stair-verification row to the top "next up" priority. Note A3 is now unblocked. + +- [ ] **Step 4: Run final test suite as the sign-off check** + +``` +dotnet test -c Debug --nologo --verbosity minimal +``` + +Expected: all green. ~1139 passing. + +- [ ] **Step 5: Commit the doc updates** + +``` +git add docs/plans/2026-04-11-roadmap.md CLAUDE.md docs/research/2026-05-21-open-items-pickup-prompt.md +git commit -m "$(cat <<'EOF' +docs(roadmap): mark Phase A4 (multi-cell BSP) shipped + +Multi-cell BSP iteration landed in three commits today, closing the +Holtburg inn vestibule wall walk-through. CLAUDE.md updated with the +Phase A4 ship paragraph; roadmap shipped-table gains a row; open-items +pickup prompt marks A4 closed and re-orders the remaining items +(stair verification → A2 → A3 → lighting). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review summary + +**Spec coverage:** +- ✅ Architecture §1 `CellTransit.FindCellSet` → Task 1. +- ✅ Architecture §2 `Transition.CheckOtherCells` → Task 2. +- ✅ Architecture §3 `FindEnvCollisions` wire-up → Task 3. +- ✅ Unit tests §`CellTransitFindCellSetTests` → Task 1 (3 tests, matches spec). +- ✅ Unit tests §`TransitionCheckOtherCellsTests` → Task 2 (6 tests: 5 against the `ApplyOtherCellResult` combine helper for halt semantics, 1 direct invocation of `CheckOtherCells` for the spec's `NullBspRootIsSkipped` guard). Diverges from spec's exact test names (combine-focused vs iteration-focused) but covers the same surface and is more testable — no need to engineer BSPs that return specific Slid/Adjusted states. +- ✅ Integration test §`FindEnvCollisionsMultiCellTests` → Task 3 (1 test, matches spec). +- ✅ Visual acceptance § → Task 4. +- ✅ Roadmap + handoff update § → Task 5. + +**Placeholder scan:** No "TBD" / "TODO" / "implement later." All code is complete. Commit SHA placeholders in Task 5 (`` etc.) are intentional — they're filled in at execution time, not write time. + +**Type consistency:** +- `FindCellSet`'s out-parameter type is `IReadOnlyCollection` everywhere (test, implementation, spec). +- `CheckOtherCells`'s `cellSet` parameter type is `IReadOnlyCollection` matching FindCellSet's out type. +- `ApplyOtherCellResult` returns `bool` (halt) + out `TransitionState` everywhere. + +**Scope check:** Single coherent slice. Three commits, one visual verification, one doc update. ~380 LOC. PR-sized. + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md`.