# 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<ushort, ResolvedPolygon>(),
            PortalPolygons        = new Dictionary<ushort, ResolvedPolygon> { [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<ushort, ResolvedPolygon>(),
            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
    /// <summary>
    /// Top-level cell-tracking driver, ported from retail's
    /// <c>CObjCell::find_cell_list</c> (sphere variant).
    ///
    /// <para>
    /// Walks the portal graph from <paramref name="currentCellId"/>,
    /// finds the cell whose <see cref="CellPhysics.CellBSP"/> contains
    /// the sphere center, and returns its full id (landblock-prefixed).
    /// Falls back to <paramref name="currentCellId"/> when no candidate
    /// matches. The candidate set built internally is discarded; use
    /// <see cref="FindCellSet"/> to recover it.
    /// </para>
    ///
    /// <para>
    /// Pseudocode reference:
    /// <c>docs/research/acclient_indoor_transitions_pseudocode.md</c>
    /// §"Overall Driver: find_cell_list".
    /// </para>
    /// </summary>
    public static uint FindCellList(
        PhysicsDataCache cache,
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId)
    {
        return BuildCellSetAndPickContaining(
            cache, worldSphereCenter, sphereRadius, currentCellId,
            out _);
    }

    /// <summary>
    /// Phase A4 (2026-05-20). Same portal-graph traversal as
    /// <see cref="FindCellList"/> but additionally returns the full
    /// candidate set built during traversal. Used by
    /// <see cref="Transition.CheckOtherCells"/> to iterate every cell
    /// the sphere overlaps for per-cell BSP collision.
    ///
    /// <para>
    /// Retail oracle: <c>CTransition::check_other_cells</c> at
    /// <c>acclient_2013_pseudo_c.txt:272717-272798</c> calls
    /// <c>CObjCell::find_cell_list(&amp;this-&gt;cell_array, &amp;var_4c, ...)</c>
    /// which fills both the cell_array (set) and var_4c (containing cell).
    /// </para>
    /// </summary>
    public static uint FindCellSet(
        PhysicsDataCache cache,
        Vector3 worldSphereCenter,
        float sphereRadius,
        uint currentCellId,
        out IReadOnlyCollection<uint> 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<uint> candidates)
    {
        candidates = new HashSet<uint>();
        uint currentLow = currentCellId & 0xFFFFu;

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

            candidates.Add(currentCellId);

            var pending = new Queue<uint>();
            var visited = new HashSet<uint>();
            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<uint>(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) <noreply@anthropic.com>
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;

/// <summary>
/// Unit tests for the result-combine helper used by
/// <see cref="Transition.CheckOtherCells"/>. The iteration / per-cell
/// BSP-query parts are covered end-to-end by
/// <see cref="FindEnvCollisionsMultiCellTests"/>; this file pins the
/// retail-faithful halt semantics that
/// <c>acclient_2013_pseudo_c.txt:272739-272752</c> spells out.
/// </summary>
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<ushort, ResolvedPolygon>(),
        };

        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<CellSurface>(), Array.Empty<PortalPlane>(),
            worldOffsetX: 0f, worldOffsetY: 0f);
        engine.DataCache.RegisterCellStructForTest(0xA9B40157u, cell);

        var t = MakeTransition();
        var cellSet = new HashSet<uint> { 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
    /// <summary>
    /// Phase A4 (2026-05-20). Port of retail's
    /// <c>CTransition::check_other_cells</c> at
    /// <c>acclient_2013_pseudo_c.txt:272717-272798</c>.
    ///
    /// <para>
    /// 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 <see cref="Transition"/> state between cells.
    /// </para>
    /// </summary>
    internal TransitionState CheckOtherCells(
        PhysicsEngine engine,
        Vector3 footCenter,
        float sphereRadius,
        System.Collections.Generic.IReadOnlyCollection<uint> 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<uint>(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;
    }

    /// <summary>
    /// Phase A4 (2026-05-20). Combine helper for
    /// <see cref="CheckOtherCells"/>. Mirrors retail's switch at
    /// <c>acclient_2013_pseudo_c.txt:272739-272752</c>:
    /// Collided/Adjusted halt with <c>CollidedWithEnvironment</c>; Slid
    /// halts AND clears the contact-plane fields; OK continues.
    /// </summary>
    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) <noreply@anthropic.com>
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;

/// <summary>
/// End-to-end test that the indoor branch of
/// <see cref="Transition.FindEnvCollisions"/> 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.
/// </summary>
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<ushort, ResolvedPolygon> 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<ushort, ResolvedPolygon> { [wallId] = wallPoly };

        var bsp = new PhysicsBSPTree
        {
            Root = new PhysicsBSPNode
            {
                Type           = BSPNodeType.Leaf,
                BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 20f },
                Polygons       = new List<ushort> { 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<ushort, ResolvedPolygon>(),
            CellBSP               = CellBspContainingOrigin(),
            PortalPolygons        = new Dictionary<ushort, ResolvedPolygon> { [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<CellSurface>(), Array.Empty<PortalPlane>(),
            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) <noreply@anthropic.com>
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. | <commit-sha-of-task3> |
```

(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:
- `<task1-sha>` — CellTransit.FindCellSet overload exposes the candidate set
- `<task2-sha>` — Transition.CheckOtherCells + ApplyOtherCellResult port
- `<task3-sha>` — 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 `<task1-sha>` / `<task2-sha>` / `<task3-sha>` 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) <noreply@anthropic.com>
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 (`<task1-sha>` etc.) are intentional — they're filled in at execution time, not write time.

**Type consistency:**
- `FindCellSet`'s out-parameter type is `IReadOnlyCollection<uint>` everywhere (test, implementation, spec).
- `CheckOtherCells`'s `cellSet` parameter type is `IReadOnlyCollection<uint>` 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`.
