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>
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(extractFindCellListbody, 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
FindCellSetby refactoringFindCellList
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(&this->cell_array, &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(addCheckOtherCells+ApplyOtherCellResultmethods) -
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 inFindEnvCollisions) -
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
CheckOtherCellsintoFindEnvCollisions
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 (commitde8ffde) 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:
- Walk to the Holtburg inn front door, enter the vestibule (cell 0xA9B40164, the small entrance area).
- Try to walk through walls in the adjacent room (cell 0xA9B40157). They should BLOCK now.
- Walk up the inn stairs — riser walls should also block, not pass through.
- Walk back outside the inn — no regression in "thin air" collision around the building (A1/A1.5/A1.6 still work).
- 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
FindEnvCollisionswire-up → Task 3. - ✅ Unit tests §
CellTransitFindCellSetTests→ Task 1 (3 tests, matches spec). - ✅ Unit tests §
TransitionCheckOtherCellsTests→ Task 2 (6 tests: 5 against theApplyOtherCellResultcombine helper for halt semantics, 1 direct invocation ofCheckOtherCellsfor the spec'sNullBspRootIsSkippedguard). 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 isIReadOnlyCollection<uint>everywhere (test, implementation, spec).CheckOtherCells'scellSetparameter type isIReadOnlyCollection<uint>matching FindCellSet's out type.ApplyOtherCellResultreturnsbool(halt) + outTransitionStateeverywhere.
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.