acdream/docs/superpowers/plans/2026-05-20-phase-a4-multi-cell-bsp.md
Erik a8a0366eb1 docs(plan): Phase A4 — multi-cell BSP implementation plan
Five tasks: pre-flight baseline → CellTransit.FindCellSet (3 tests + impl
+ commit) → Transition.CheckOtherCells (6 tests + impl + commit) →
FindEnvCollisions wire-up (1 integration test + commit) → visual verify
at Holtburg inn vestibule → roadmap + handoff doc update.

Each implementation task is TDD: write failing tests, verify red,
implement, verify green, run baseline, commit. Three commits land
A4 in the codebase, fourth commit lands the docs.

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>
2026-05-20 15:59:54 +02:00

46 KiB

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

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:

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:

    /// <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:

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):

    /// <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:

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:

                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:

                // ── 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
$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
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:

| 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:

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