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