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>
1156 lines
46 KiB
Markdown
1156 lines
46 KiB
Markdown
# 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(&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` (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`.
|