Five tasks (TDD throughout): 1. Extend FindWalkableInternal signature with ref ushort hitPolyId (mechanical, two existing callers pass discards). 2. Add BSPQuery.FindWalkableSphere wrapper + 4 unit tests (two-floors-foot-between, only-upper-floor-foot-above, no-walkable- in-range, steep-poly-rejected). 3. Refactor TryFindIndoorWalkablePlane through BSPQuery.FindWalkableSphere with sphereRadius thread + WalkableAllowance save/restore, delete PointInPolygonXY, update single callsite, integration test for two-overlapping-floors + allowance-preservation. 4. Add [indoor-walkable] probe line on existing PhysicsDiagnostics flag. 5. Visual verification by user + roadmap + ISSUES #83 close. Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
50 KiB
Indoor Walkable-Plane BSP Port — 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.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md (committed 165f67a)
Goal: Route Transition.TryFindIndoorWalkablePlane through the existing retail-faithful BSP walkable-finder (BSPQuery.FindWalkableInternal) via a thin new public wrapper, so indoor walkable-plane synthesis picks the polygon closest to the foot along the up vector instead of the first walkable polygon in dictionary order. Fixes cellar descent, 2nd-floor walking, and the suspected invisible-obstacle cascade indoors.
Architecture: One small extension (FindWalkableInternal gains a ref ushort hitPolyId so the dictionary key surfaces to the caller), one new ~30-line public wrapper (BSPQuery.FindWalkableSphere), one refactored helper body (Transition.TryFindIndoorWalkablePlane), one deleted dead helper (Transition.PointInPolygonXY), one extended diagnostic probe line ([indoor-walkable]). All other physics code paths unchanged.
Tech Stack: C# / .NET 10, Silk.NET (not touched here), xUnit for tests, existing PhysicsDiagnostics for probes.
Build / test invocations (used throughout):
- Full build:
dotnet build -c Debug - Targeted test run:
dotnet test --filter "FullyQualifiedName~BSPQueryTests"for BSP tests,dotnet test --filter "FullyQualifiedName~TransitionTypesTests"for transition tests - Full suite:
dotnet test - The 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline) must remain exactly 8 — no new failures.
File Structure
| File | Action | Lines (approx) | Purpose |
|---|---|---|---|
src/AcDream.Core/Physics/BSPQuery.cs |
Modify | Extends FindWalkableInternal signature (+1 ref param) and updates 4 internal recursion sites + 2 external callers (StepSphereDown, Path 4 Collide). Adds new FindWalkableSphere public wrapper. |
The walkable-finder lives here; one self-contained file. |
src/AcDream.Core/Physics/TransitionTypes.cs |
Modify | Refactors TryFindIndoorWalkablePlane body, adds sphereRadius parameter, deletes PointInPolygonXY helper. Adds [indoor-walkable] probe line in FindEnvCollisions. |
The indoor walkable-plane synthesis lives here. |
tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs |
Modify (append) | Adds 4 new unit tests for FindWalkableSphere (closest-below-foot, closest-above-foot-when-only-above, no-walkable-in-range, steep-poly-rejected). |
Existing BSP test suite. |
tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs |
Create OR Modify (append) | Adds 1 integration test asserting TryFindIndoorWalkablePlane routes through BSPQuery.FindWalkableSphere and preserves WalkableAllowance. |
New or existing transition test file (check during Task 4). |
docs/plans/2026-04-11-roadmap.md |
Modify | Adds "Indoor walkable-plane BSP port" row to the shipped table after visual verification passes. | Strategic roadmap. |
docs/ISSUES.md |
Modify | Closes #83 after visual verification of scenarios 4 & 5. | Tactical issues list. |
Task 1: Extend BSPQuery.FindWalkableInternal to track polyId
Files:
- Modify:
src/AcDream.Core/Physics/BSPQuery.cs:647-705(FindWalkableInternal body) - Modify:
src/AcDream.Core/Physics/BSPQuery.cs:1085-1128(StepSphereDown caller) - Modify:
src/AcDream.Core/Physics/BSPQuery.cs:1486-1532(Path 4 Collide caller)
This is a pure mechanical signature extension. Since FindWalkableInternal already iterates foreach (ushort polyId in node.Polygons) in its leaf branch (line 665), the polyId is already in scope — we just expose it to the caller. No behavior change for the two existing callers; they pass a discard. The new FindWalkableSphere wrapper (Task 2) will use the new param.
- Step 1.1: Modify
FindWalkableInternalsignature and body
Change BSPQuery.cs:647-705:
private static void FindWalkableInternal(
PhysicsBSPNode? node,
Dictionary<ushort, ResolvedPolygon> resolved,
SpherePath path,
CollisionSphere validPos,
Vector3 movement,
Vector3 up,
ref ResolvedPolygon? hitPoly,
ref ushort hitPolyId,
ref bool changed)
{
if (node is null) return;
if (!NodeIntersects(node, validPos)) return;
// Leaf.
if (node.Type == BSPNodeType.Leaf)
{
if (node.Polygons.Count == 0) return;
foreach (ushort polyId in node.Polygons)
{
if (!resolved.TryGetValue(polyId, out var poly)) continue;
bool walkable = WalkableHitsSphere(poly, path, validPos, up);
bool adjusted = walkable && AdjustSphereToPlane(poly, path, validPos, movement);
if (walkable && adjusted)
{
changed = true;
hitPoly = poly;
hitPolyId = polyId;
}
}
return;
}
// Internal: classify against splitting plane.
float dist = Vector3.Dot(node.SplittingPlane.Normal, validPos.Center)
+ node.SplittingPlane.D;
float reach = validPos.Radius - PhysicsGlobals.EPSILON;
if (dist >= reach)
{
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
ref hitPoly, ref hitPolyId, ref changed);
return;
}
if (dist <= -reach)
{
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
ref hitPoly, ref hitPolyId, ref changed);
return;
}
// Straddles.
FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
ref hitPoly, ref hitPolyId, ref changed);
FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
ref hitPoly, ref hitPolyId, ref changed);
}
- Step 1.2: Update
StepSphereDowncaller
Change BSPQuery.cs:1085-1128:
private static TransitionState StepSphereDown(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> resolved,
Transition transition,
CollisionSphere checkPos,
Vector3 up,
float scale,
Quaternion localToWorld = default,
Vector3 worldOrigin = default)
{
if (localToWorld == default) localToWorld = Quaternion.Identity;
var path = transition.SpherePath;
var collisions = transition.CollisionInfo;
float stepDownAmount = -(path.StepDownAmt * path.WalkInterp);
var movement = up * stepDownAmount * (1f / scale);
var validPos = new CollisionSphere(checkPos);
bool changed = false;
ResolvedPolygon? polyHit = null;
ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it
FindWalkableInternal(root, resolved, path, validPos, movement, up,
ref polyHit, ref _polyId, ref changed);
if (changed && polyHit is not null)
{
// ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale
var adjusted = validPos.Center - checkPos.Center;
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
path.AddOffsetToCheckPos(offset);
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
return TransitionState.OK;
}
- Step 1.3: Update Path 4 (Collide) caller
In BSPQuery.cs:1486-1496 change the call site:
if (path.Collide)
{
var validPos = new CollisionSphere(sphere0);
ResolvedPolygon? hitPoly = null;
ushort _hitPolyId = 0; // Path 4 doesn't need the id
bool changed = false;
FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ,
ref hitPoly, ref _hitPolyId, ref changed);
if (changed && hitPoly is not null)
{
// ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
(Only lines 1488-1493 change — the discard local addition and the new ref _hitPolyId argument.)
- Step 1.4: Build and verify no regression
Run:
dotnet build -c Debug
Expected: clean build (zero errors, zero warnings introduced).
Run:
dotnet test --filter "FullyQualifiedName~BSPQueryTests"
Expected: all existing BSPQueryTests pass (no behavior change yet).
Run:
dotnet test
Expected: same 8 pre-existing failures as before (MotionInterpreter / BSPStepUp baseline). No new failures.
- Step 1.5: Commit
git add src/AcDream.Core/Physics/BSPQuery.cs
git commit -m "$(cat <<'EOF'
refactor(physics): expose hitPolyId from FindWalkableInternal
Adds a ref ushort hitPolyId parameter to FindWalkableInternal so callers
can identify which polygon was hit. The leaf branch already iterates
foreach (ushort polyId in node.Polygons); this surfaces it.
No behavior change. Existing callers (StepSphereDown, Path 4 Collide)
pass a discard local. The new BSPQuery.FindWalkableSphere wrapper
(next commit) will consume it.
Prep for indoor walkable-plane BSP port — see spec
docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
EOF
)"
Task 2: Add BSPQuery.FindWalkableSphere public wrapper + unit tests
Files:
- Modify:
src/AcDream.Core/Physics/BSPQuery.cs— add new public method adjacent toStepSphereDown(~line 1085) - Modify:
tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs— append 4 new unit tests
TDD order: write the 4 failing tests first, then implement.
- Step 2.1: Append test helpers and Test 1 to BSPQueryTests.cs (failing)
Append at the end of tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs (inside the existing BSPQueryTests class, before the closing brace):
// =========================================================================
// FindWalkableSphere — indoor walkable-plane finder (spec 2026-05-19)
// =========================================================================
/// <summary>
/// Build a single-leaf BSP rooted at one node containing two horizontal
/// walkable polygons (id 0 at Z=lowerZ, id 1 at Z=upperZ), both covering
/// the unit square X[0..1] × Y[0..1]. Bounding sphere is sized to enclose
/// both polys.
/// </summary>
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
BuildTwoFloorsBsp(float lowerZ, float upperZ)
{
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = center, Radius = radius },
};
root.Polygons.Add(0);
root.Polygons.Add(1);
Vector3[] lowerVerts =
{
new Vector3(0f, 0f, lowerZ),
new Vector3(1f, 0f, lowerZ),
new Vector3(1f, 1f, lowerZ),
new Vector3(0f, 1f, lowerZ),
};
Vector3[] upperVerts =
{
new Vector3(0f, 0f, upperZ),
new Vector3(1f, 0f, upperZ),
new Vector3(1f, 1f, upperZ),
new Vector3(0f, 1f, upperZ),
};
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = lowerVerts,
Plane = new Plane(Vector3.UnitZ, -lowerZ),
NumPoints = 4,
SidesType = CullMode.None,
},
[1] = new ResolvedPolygon
{
Vertices = upperVerts,
Plane = new Plane(Vector3.UnitZ, -upperZ),
NumPoints = 4,
SidesType = CullMode.None,
},
};
return (root, resolved);
}
/// <summary>
/// Build a Transition with WalkableAllowance set to FloorZ — what the
/// indoor walkable-plane synthesis uses.
/// </summary>
private static Transition BuildFloorZTransition()
{
var transition = new Transition();
transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
transition.SpherePath.WalkInterp = 1.0f;
return transition;
}
[Fact]
public void FindWalkableSphere_TwoFloors_FootBetween_PicksLowerFloor()
{
// Two floors at Z=0 and Z=3. Foot sphere center at Z=1.0 (radius 0.48).
// Downward probe of 0.5m → sphere swept from Z=1.0 down to Z=0.5.
// Lower floor is at Z=0; sphere will collide with it (top of sphere
// reaches Z=1.48; with downward sweep, bottom reaches Z=0.02). Upper
// floor at Z=3 is unreachable in either direction within probe range.
// Expect: pick lower floor (id 0).
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
var transition = BuildFloorZTransition();
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 1.0f), Radius = 0.48f };
bool found = BSPQuery.FindWalkableSphere(
root, resolved, transition,
sphere,
probeDistance: 0.5f,
up: Vector3.UnitZ,
out var hitPoly,
out var hitPolyId,
out var adjustedCenter);
Assert.True(found);
Assert.Equal((ushort)0, hitPolyId);
Assert.NotNull(hitPoly);
Assert.Equal(0f, hitPoly!.Plane.Normal.Z, precision: 3); // sanity
}
- Step 2.2: Run test, verify it fails with "FindWalkableSphere not defined"
Run:
dotnet test --filter "FullyQualifiedName~FindWalkableSphere_TwoFloors_FootBetween" --no-restore
Expected: compile error or test failure citing BSPQuery.FindWalkableSphere not found.
- Step 2.3: Implement
BSPQuery.FindWalkableSphere
Insert into src/AcDream.Core/Physics/BSPQuery.cs immediately AFTER StepSphereDown (after line ~1128, before the // step_sphere_up section header at line ~1130):
// -------------------------------------------------------------------------
// find_walkable_sphere — "stand here, find my contact plane"
// Indoor walkable-plane synthesis entry point (Phase 2 follow-up 2026-05-19).
// -------------------------------------------------------------------------
/// <summary>
/// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
/// find_walkable BSP traversal. Probes downward by <paramref name="probeDistance"/>
/// along <paramref name="up"/> and returns the closest walkable polygon the
/// sphere would rest on, with the sphere's center adjusted to lie on that plane.
///
/// <para>
/// Wraps the existing private <see cref="FindWalkableInternal"/> — which already
/// implements the retail-faithful walkable-finder
/// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
/// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
/// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
/// </para>
///
/// <para>
/// Intended call site: indoor walkable-plane synthesis in
/// <c>Transition.TryFindIndoorWalkablePlane</c> when the indoor cell-BSP
/// collision returns OK (no wall hit) and the resolver still needs a
/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
/// (<see cref="PhysicsEngine.SampleTerrainWalkable"/>) and does not use this.
/// </para>
///
/// <para>
/// The caller is responsible for setting <c>transition.SpherePath.WalkableAllowance</c>
/// to the desired walkability threshold (typically <see cref="PhysicsGlobals.FloorZ"/>)
/// before calling, and restoring it after.
/// </para>
/// </summary>
/// <param name="root">Root of the cell's PhysicsBSP.</param>
/// <param name="resolved">Pre-resolved polygon dictionary from PhysicsDataCache.</param>
/// <param name="transition">Current transition (read for WalkableAllowance / walk_interp).</param>
/// <param name="sphere">Foot sphere in cell-local space.</param>
/// <param name="probeDistance">Downward probe distance in meters. Typical: 0.5f.</param>
/// <param name="up">Up vector in cell-local space (typically Vector3.UnitZ).</param>
/// <param name="hitPoly">Output: the walkable polygon found, or null on miss.</param>
/// <param name="hitPolyId">Output: polygon id (dictionary key) of the hit. Zero on miss.</param>
/// <param name="adjustedCenter">
/// Output: sphere center adjusted onto the polygon plane. Equal to input
/// <c>sphere.Origin</c> on miss.
/// </param>
/// <returns>True if a walkable polygon was found; false otherwise.</returns>
public static bool FindWalkableSphere(
PhysicsBSPNode? root,
Dictionary<ushort, ResolvedPolygon> resolved,
Transition transition,
Sphere sphere,
float probeDistance,
Vector3 up,
out ResolvedPolygon? hitPoly,
out ushort hitPolyId,
out Vector3 adjustedCenter)
{
adjustedCenter = sphere.Origin;
hitPoly = null;
hitPolyId = 0;
if (root is null) return false;
var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
var movement = -up * probeDistance;
bool changed = false;
ushort polyId = 0;
ResolvedPolygon? poly = null;
FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
movement, up, ref poly, ref polyId, ref changed);
if (changed && poly is not null)
{
hitPoly = poly;
hitPolyId = polyId;
adjustedCenter = validPos.Center;
return true;
}
return false;
}
- Step 2.4: Run test, verify it passes
Run:
dotnet test --filter "FullyQualifiedName~FindWalkableSphere_TwoFloors_FootBetween"
Expected: PASS.
- Step 2.5: Append Test 2 (foot above only-upper-floor picks upper)
Append to BSPQueryTests.cs after Test 1:
[Fact]
public void FindWalkableSphere_OnlyUpperFloor_FootAbove_PicksUpperFloor()
{
// One floor at Z=3. Foot sphere at Z=3.5 with downward probe 0.5m.
// Sphere sweeps from Z=3.5 to Z=3.0; upper-floor surface at Z=3 is
// within reach. Expect: pick the upper floor (id 1).
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
var transition = BuildFloorZTransition();
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 3.5f), Radius = 0.48f };
bool found = BSPQuery.FindWalkableSphere(
root, resolved, transition,
sphere,
probeDistance: 0.5f,
up: Vector3.UnitZ,
out var hitPoly,
out var hitPolyId,
out _);
Assert.True(found);
Assert.Equal((ushort)1, hitPolyId);
Assert.NotNull(hitPoly);
}
[Fact]
public void FindWalkableSphere_NoWalkableInProbeRange_ReturnsFalse()
{
// Two floors at Z=0 and Z=3. Foot at Z=10 with probe 0.5m — out of
// range of both polygons even considering sphere radius (sweep covers
// Z=9.52..10.48, no overlap with Z=0 or Z=3).
var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
var transition = BuildFloorZTransition();
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 10f), Radius = 0.48f };
bool found = BSPQuery.FindWalkableSphere(
root, resolved, transition,
sphere,
probeDistance: 0.5f,
up: Vector3.UnitZ,
out var hitPoly,
out var hitPolyId,
out var adjustedCenter);
Assert.False(found);
Assert.Null(hitPoly);
Assert.Equal((ushort)0, hitPolyId);
Assert.Equal(sphere.Origin, adjustedCenter);
}
[Fact]
public void FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance()
{
// One polygon with a steep normal (Z component = 0.5 < FloorZ ≈ 0.6664).
// walkable_hits_sphere should reject it: dp = N·up = 0.5,
// WalkableAllowance = FloorZ, so dp <= WalkableAllowance is true → not walkable.
var center = new Vector3(0.5f, 0.5f, 0f);
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = center, Radius = 2f },
};
root.Polygons.Add(0);
// Plane tilted: normal has Z = 0.5 (52° slope).
var steepNormal = Vector3.Normalize(new Vector3(0f, MathF.Sqrt(0.75f), 0.5f));
Vector3[] verts =
{
new Vector3(0f, 0f, 0f),
new Vector3(1f, 0f, 0f),
new Vector3(1f, 1f, MathF.Sqrt(0.75f) / 0.5f),
new Vector3(0f, 1f, MathF.Sqrt(0.75f) / 0.5f),
};
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(steepNormal, -Vector3.Dot(steepNormal, verts[0])),
NumPoints = 4,
SidesType = CullMode.None,
},
};
var transition = BuildFloorZTransition();
var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.5f), Radius = 0.48f };
bool found = BSPQuery.FindWalkableSphere(
root, resolved, transition,
sphere,
probeDistance: 0.5f,
up: Vector3.UnitZ,
out _,
out _,
out _);
Assert.False(found);
}
- Step 2.6: Run all 4 FindWalkableSphere tests, verify they pass
Run:
dotnet test --filter "FullyQualifiedName~FindWalkableSphere"
Expected: 4 tests pass.
- Step 2.7: Full BSPQuery test run — no regressions
Run:
dotnet test --filter "FullyQualifiedName~BSPQueryTests"
Expected: all existing tests pass plus 4 new ones.
Run:
dotnet test
Expected: same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). 4 new tests pass. No new failures.
- Step 2.8: Commit
git add src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
git commit -m "$(cat <<'EOF'
feat(physics): add BSPQuery.FindWalkableSphere wrapper
Thin public wrapper over the existing retail-faithful
FindWalkableInternal (BSPNODE::find_walkable + BSPLEAF::find_walkable
port). Probes downward by probeDistance along up, returns the closest
walkable polygon the sphere would rest on plus the adjusted center.
Will replace Transition.TryFindIndoorWalkablePlane's linear first-match
scan (next commit). The wrapper is callable from any "stand here, find
my floor" use case; current intent is indoor walkable-plane synthesis.
4 unit tests covering: two-floors-foot-between, only-upper-floor-foot-above,
no-walkable-in-probe-range, steep-poly-rejected-by-WalkableAllowance.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
EOF
)"
Task 3: Refactor TryFindIndoorWalkablePlane to use FindWalkableSphere
Files:
- Modify:
src/AcDream.Core/Physics/TransitionTypes.cs:1192-1253(helper body + delete PointInPolygonXY) - Modify:
src/AcDream.Core/Physics/TransitionTypes.cs:1358(single callsite — thread sphereRadius)
TDD: write the integration test first (asserts BSPQuery is called AND WalkableAllowance is restored), then implement.
- Step 3.1: Create or locate the transition tests file
Check whether tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs exists:
ls tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
If it doesn't exist, create it with this header:
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Tests.Physics;
public class TransitionTypesTests
{
// (tests will be appended here)
}
If it exists, skip the file creation; just append the test in step 3.2.
- Step 3.2: Append integration test (failing)
Append inside the TransitionTypesTests class:
[Fact]
public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
{
// Build a CellPhysics with two horizontal walkable polygons at
// local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
// Foot at local Z=1.0 → should find the Z=0 polygon (closer below).
// Sentinel WalkableAllowance value must be preserved across the call.
var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
var transition = new Transition();
const float sentinelAllowance = 0.42f;
transition.SpherePath.WalkableAllowance = sentinelAllowance;
transition.SpherePath.WalkInterp = 1.0f;
bool found = transition.TryFindIndoorWalkablePlane(
cellPhysics,
localFootCenter: new Vector3(0.5f, 0.5f, 1.0f),
sphereRadius: 0.48f,
out var worldPlane,
out var worldVertices,
out var hitPolyId);
Assert.True(found);
// The lower polygon's local plane Normal.Z = 1.0; identity world transform
// means the world Normal.Z is also 1.0.
Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
// World vertices should match the lower polygon (Z=0 in world space).
Assert.Equal(4, worldVertices.Length);
Assert.Equal(0f, worldVertices[0].Z, precision: 3);
// hitPolyId is the dictionary key — lower polygon was inserted as key 0.
Assert.Equal(0u, hitPolyId);
// WalkableAllowance must be restored to the sentinel.
Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
}
/// <summary>
/// Build a minimal CellPhysics with two horizontal walkable polygons at
/// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
/// </summary>
private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
{
Vector3[] lowerVerts =
{
new Vector3(0f, 0f, lowerZ),
new Vector3(1f, 0f, lowerZ),
new Vector3(1f, 1f, lowerZ),
new Vector3(0f, 1f, lowerZ),
};
Vector3[] upperVerts =
{
new Vector3(0f, 0f, upperZ),
new Vector3(1f, 0f, upperZ),
new Vector3(1f, 1f, upperZ),
new Vector3(0f, 1f, upperZ),
};
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = lowerVerts,
Plane = new Plane(Vector3.UnitZ, -lowerZ),
NumPoints = 4,
SidesType = CullMode.None,
},
[1] = new ResolvedPolygon
{
Vertices = upperVerts,
Plane = new Plane(Vector3.UnitZ, -upperZ),
NumPoints = 4,
SidesType = CullMode.None,
},
};
var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = center, Radius = radius },
};
root.Polygons.Add(0);
root.Polygons.Add(1);
var bsp = new PhysicsBSPTree { Root = root };
return new CellPhysics
{
BSP = bsp,
Resolved = resolved,
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
// Other CellPhysics fields are not consulted by TryFindIndoorWalkablePlane.
};
}
Note on the helper: CellPhysics is required-property-heavy. If the build fails on "missing required member", check src/AcDream.Core/Physics/PhysicsDataCache.cs around line 410 (the CellPhysics record) and add minimal init values for any required properties (typically empty collections or null where the type permits). Do not extend test fixtures beyond what TryFindIndoorWalkablePlane reads — keep the fixture minimal.
- Step 3.3: Run test, verify it fails on signature mismatch
Run:
dotnet test --filter "FullyQualifiedName~TryFindIndoorWalkablePlane_TwoOverlappingFloors"
Expected: compile error citing TryFindIndoorWalkablePlane doesn't take sphereRadius parameter, OR an instance-vs-static method mismatch (the spec changes internal static to internal).
- Step 3.4: Refactor
TryFindIndoorWalkablePlane
Replace src/AcDream.Core/Physics/TransitionTypes.cs:1192-1232 (the full body of the existing TryFindIndoorWalkablePlane method) with:
/// <summary>
/// Synthesize the indoor walkable contact plane for the player's current
/// position when the cell BSP returns OK (no wall collision).
///
/// <para>
/// Routes through the retail-faithful BSP walkable-finder
/// (<see cref="BSPQuery.FindWalkableSphere"/>) — which traverses the cell
/// PhysicsBSP and picks the polygon closest to the foot along the up vector.
/// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
/// stop-gap; that scan picked the wrong floor whenever two polygons
/// overlapped in XY at different Z (cellars, 2nd floors, balconies).
/// </para>
///
/// <para>
/// Returns <c>false</c> if no walkable floor poly is found under the
/// player. The caller falls through to outdoor terrain in that case
/// (defensive backstop — should not normally happen inside a sealed cell).
/// </para>
///
/// <para>
/// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
/// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
/// CPolygon::adjust_sphere_to_plane (:322032).
/// </para>
/// </summary>
internal bool TryFindIndoorWalkablePlane(
CellPhysics cellPhysics,
Vector3 localFootCenter,
float sphereRadius,
out System.Numerics.Plane worldPlane,
out Vector3[] worldVertices,
out uint hitPolyId)
{
worldPlane = default;
worldVertices = System.Array.Empty<Vector3>();
hitPolyId = 0;
if (cellPhysics.BSP?.Root is null) return false;
// Build foot sphere in cell-local space. Caller passes localFootCenter
// already transformed into cell-local space and the resolver's
// foot-sphere radius.
var localSphere = new DatReaderWriter.Types.Sphere
{
Origin = localFootCenter,
Radius = sphereRadius,
};
// Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
// path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
// "standing here, find my floor" we want the walkability slope
// threshold FloorZ. The outer resolver may have set it to LandingZ
// (airborne→ground transition) or another value; we must not leak our
// change back to the resolver. try/finally so an exception inside
// FindWalkableSphere doesn't leak the modified state.
float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
ResolvedPolygon? hitPoly = null;
ushort hitId = 0;
Vector3 adjustedCenter;
bool found;
try
{
found = BSPQuery.FindWalkableSphere(
cellPhysics.BSP.Root,
cellPhysics.Resolved,
this,
localSphere,
INDOOR_WALKABLE_PROBE_DISTANCE,
Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
out hitPoly,
out hitId,
out adjustedCenter);
}
finally
{
this.SpherePath.WalkableAllowance = savedWalkableAllowance;
}
if (!found || hitPoly is null) return false;
// Transform hit polygon's plane + vertices to world space. Math is
// unchanged from the previous TryFindIndoorWalkablePlane implementation.
var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
worldNormal = Vector3.Normalize(worldNormal);
var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
float worldD = -Vector3.Dot(worldNormal, worldV0);
worldPlane = new System.Numerics.Plane(worldNormal, worldD);
worldVertices = new Vector3[hitPoly.Vertices.Length];
for (int i = 0; i < hitPoly.Vertices.Length; i++)
worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
hitPolyId = hitId;
return true;
}
/// <summary>
/// Downward probe distance used by <see cref="TryFindIndoorWalkablePlane"/>
/// when scanning for the indoor walkable contact plane. 50 cm.
/// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
/// step riser; smaller than a full cell height so we don't reach through
/// a thin floor into the cell above/below.
/// </summary>
private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
Notes on the change:
-
Method went from
internal statictointernal(instance method) —this.SpherePathaccess requires it. The single callsite at line 1358 is already inside aTransitioninstance method. -
Removed
statickeyword. -
Added
float sphereRadiusparameter. -
Replaced the linear scan body and the
PointInPolygonXYcall with aBSPQuery.FindWalkableSpherecall wrapped in try/finally for allowance save/restore. -
Step 3.5: Delete
PointInPolygonXYhelper
Remove src/AcDream.Core/Physics/TransitionTypes.cs:1234-1253 (the entire PointInPolygonXY method including its leading <summary> doc comment block). Verify no other callers via:
grep -rn PointInPolygonXY src/ tests/
Expected: zero matches after deletion.
- Step 3.6: Update the single callsite in
FindEnvCollisions
At src/AcDream.Core/Physics/TransitionTypes.cs:1358, change:
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
out var indoorPlane,
out var indoorVertices,
out uint _))
to:
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint _))
sphereRadius is already bound at line 1268 in scope of this method.
- Step 3.7: Build and run integration test
Run:
dotnet build -c Debug
Expected: clean build.
Run:
dotnet test --filter "FullyQualifiedName~TryFindIndoorWalkablePlane_TwoOverlappingFloors"
Expected: PASS.
- Step 3.8: Full suite — no regressions
Run:
dotnet test
Expected: same 8 pre-existing failures. 5 new tests pass (4 from Task 2 + 1 from this task). No new failures.
- Step 3.9: Commit
git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
git commit -m "$(cat <<'EOF'
fix(physics): route indoor walkable-plane synthesis through retail BSP walker
TryFindIndoorWalkablePlane (Phase 2 commit eb0f772) used a linear
first-match XY scan of cellPhysics.Resolved with no Z-proximity test.
For any cell with two walkable polys overlapping in XY at different Z
(cellars, 2nd floors, balconies, stairs spanning floors), it returned
whichever polygon came first in dictionary order — typically the upper
floor when descending, causing the player to be reported below the
synthesized plane → ValidateWalkable fails → falling-stuck. Symptoms
reported by user 2026-05-19: cannot descend into cellar; cannot walk
on 2nd floor; "invisible obstacles at certain spots" (suspected
cascade from wrong-Z ContactPlane misrouting the resolver state).
Fix: route through BSPQuery.FindWalkableSphere (added previous commit),
which wraps the existing retail-faithful FindWalkableInternal
(BSPNODE::find_walkable + BSPLEAF::find_walkable port). Adds a
sphereRadius parameter to TryFindIndoorWalkablePlane so the foot
sphere is built with the actual entity radius rather than a guess.
WalkableAllowance is save/restored via try/finally so the slope
threshold used by walkable_hits_sphere doesn't leak back to the
resolver. Method becomes an instance method (was static) to access
this.SpherePath.
Deletes the now-dead PointInPolygonXY helper.
Integration test covers two-overlapping-floors selection AND
WalkableAllowance preservation.
Closes (pending visual verification): ISSUES #83.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
EOF
)"
Task 4: Extend the [indoor-bsp] probe with an [indoor-walkable] line
Files:
- Modify:
src/AcDream.Core/Physics/TransitionTypes.cs— at theTryFindIndoorWalkablePlanecallsite insideFindEnvCollisions(around line 1358)
Adds one diagnostic line per call when PhysicsDiagnostics.ProbeIndoorBspEnabled is set. No new flag; no new env var. Cost-when-disabled: a single bool check.
- Step 4.1: Add
[indoor-walkable]probe line
In src/AcDream.Core/Physics/TransitionTypes.cs, modify the block around line 1358 (the TryFindIndoorWalkablePlane callsite) to emit a probe line tracking hit/miss.
Existing code (around line 1357-1377):
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint _))
{
return ValidateWalkable(
footCenter,
sphereRadius,
indoorPlane,
isWater: false,
waterDepth: 0f,
cellId: sp.CheckCellId,
walkableVertices: indoorVertices);
}
Replace with:
bool walkableHit = TryFindIndoorWalkablePlane(
cellPhysics, localCenter, sphereRadius,
out var indoorPlane,
out var indoorVertices,
out uint hitPolyId);
if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
if (walkableHit)
{
float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
// dz = signed gap between foot and synthesized plane.
// Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes)
// gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z
Console.WriteLine(System.FormattableString.Invariant(
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} "
+ $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) "
+ $"probe={0.5f:F2} result=HIT poly=0x{hitPolyId:X4} "
+ $"wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) "
+ $"wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}"));
}
else
{
Console.WriteLine(System.FormattableString.Invariant(
$"[indoor-walkable] cell=0x{sp.CheckCellId:X8} "
+ $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) "
+ $"probe={0.5f:F2} result=MISS"));
}
}
if (walkableHit)
{
return ValidateWalkable(
footCenter,
sphereRadius,
indoorPlane,
isWater: false,
waterDepth: 0f,
cellId: sp.CheckCellId,
walkableVertices: indoorVertices);
}
- Step 4.2: Build and run all tests
Run:
dotnet build -c Debug
Expected: clean build.
Run:
dotnet test
Expected: same 8 pre-existing failures. 5 new tests pass. No new failures.
- Step 4.3: Commit
git add src/AcDream.Core/Physics/TransitionTypes.cs
git commit -m "$(cat <<'EOF'
feat(physics): add [indoor-walkable] probe line
Extends the existing [indoor-bsp] probe surface in FindEnvCollisions
with a per-call [indoor-walkable] line gated on
PhysicsDiagnostics.ProbeIndoorBspEnabled (no new flag). Logs the
synthesized contact plane, the polyId hit, and the signed Z gap (dz)
between foot and plane.
Lets the visual-verification step distinguish "FindWalkableSphere
picked the right polygon" from "FindWalkableSphere returned a miss
and we fell through to outdoor-terrain backstop", which is critical
for triaging any remaining indoor collision oddities after the BSP
port lands.
Runtime-toggleable via the existing DebugPanel "Indoor BSP probe"
checkbox; zero cost when disabled.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
EOF
)"
Task 5: Visual verification + roadmap/ISSUES update
This is the acceptance gate — the user runs the client and reports results. The agent's job here is to (a) build a clean launch artifact, (b) document what to test, (c) update the roadmap + ISSUES.md after user confirmation.
Visual verification cannot be automated. The agent must NOT mark this task complete until the user has reported scenarios 1–5 as passing.
- Step 5.1: Final clean build
Run:
dotnet build -c Debug
Expected: clean build, no warnings introduced.
Run:
dotnet test
Expected: same 8 pre-existing failures, 5 new tests passing.
- Step 5.2: Present the verification scenario list to the user
Tell the user what to test, in this exact form (paste into chat):
Indoor walkable-plane BSP port — visual verification scenarios.
Launch the client (PowerShell snippet from CLAUDE.md), enable the
ACDREAM_PROBE_INDOOR_BSP=1probe to capture[indoor-walkable]lines, connect, walk to a building, and test:
- Walk into Holtburg cottage, walk around single-floor interior. Acceptance: no regression — still walks freely.
- Walk between cottage rooms via doorways. Acceptance: no regression.
- Walk back outside through cottage door. Acceptance: no regression.
- Find any building with a cellar entry; descend the stairs. Pre-fix: stuck/bounces at top. Acceptance: smooth descent onto cellar floor.
- Find any 2-story building; climb stairs; walk around upper floor. Pre-fix: snaps back to 1st floor or hits "invisible obstacles". Acceptance: stays on 2nd floor, free movement.
- (Observational, not gating) Walk near previously-reported "invisible obstacle" spots — report PASS if gone, FAIL if persist.
- (Observational, not gating) Watch bookshelves / open furnaces #88 — report whether vibration is reduced.
Capture the launch log so I can grep
[indoor-walkable]for evidence of correct polyId selection across the cellar-descent and 2nd-floor scenarios. Report back PASS/FAIL on each numbered scenario.
- Step 5.3: WAIT for user verification report
Do not proceed past this checkbox until the user has reported results for scenarios 1–5. If any of 1–5 fails, file a follow-up phase rather than marking this task done. If 6 or 7 fail, file separate issues but treat the phase as shipped (cascade hypothesis was the maybe, not the must).
- Step 5.4: Update
docs/plans/2026-04-11-roadmap.md— add shipped row
Locate the "shipped" table near the top of the doc. Add a new row in date order (after the Indoor walking Phase 2 row):
| 2026-05-19 | Indoor walkable-plane BSP port — routes TryFindIndoorWalkablePlane through retail-faithful BSPQuery.FindWalkableInternal via new FindWalkableSphere wrapper. Fixes cellar descent + 2nd-floor walking + suspected invisible-obstacle cascade. Closes #83. Spec: 2026-05-19-indoor-walkable-plane-bsp-port-design.md. Plan: 2026-05-19-indoor-walkable-plane-bsp-port.md. |
(Adjust column format to match the existing table — date, description with links if the existing rows use links.)
- Step 5.5: Update
docs/ISSUES.md— close #83
Locate the ## #83 — Walking up stairs broken section (around line 285). Change the header to indicate closure and add a resolution block, mirroring the format used for #84/#85/#87 in the same file:
## #83 — [DONE 2026-05-19 · <COMMIT_SHA_TASK_3>] Walking up stairs broken (actual symptom: walking DOWN in multi-floor cells)
**Status:** DONE
**Closed:** 2026-05-19
**Commits:** <COMMIT_SHA_TASK_1>, <COMMIT_SHA_TASK_2>, <COMMIT_SHA_TASK_3>, <COMMIT_SHA_TASK_4>
**Filed:** 2026-05-19
**Component:** physics, movement
**Resolution (2026-05-19 · Indoor walkable-plane BSP port):**
The issue title was misleading — walking UP stairs in houses always worked
(step_up routes through DoStepDown → TransitionalInsert → BSPQuery.FindCollisions
Path 3 = StepSphereDown, which already uses the retail-faithful
FindWalkableInternal BSP walker). The actual symptom was walking DOWN
in multi-floor cells (cellar descent, 2nd-floor walking, plus suspected
invisible-obstacle cascade). Root cause: Phase 2 commit eb0f772 added
TryFindIndoorWalkablePlane as a stop-gap walkable-plane synthesis when the
indoor BSP returns OK; its body did a linear first-match XY scan with no
Z-proximity test → wrong floor selected → wrong contact plane → resolver
misclassifies grounded vs airborne state.
Fix: route TryFindIndoorWalkablePlane through the existing retail-faithful
BSPQuery.FindWalkableInternal via a thin new public wrapper
(BSPQuery.FindWalkableSphere). Extends FindWalkableInternal's signature
with a ref ushort hitPolyId. Threads foot-sphere radius through
TryFindIndoorWalkablePlane's signature. Deletes the dead PointInPolygonXY
helper. WalkableAllowance is save/restored via try/finally. Adds
[indoor-walkable] probe line on the existing PhysicsDiagnostics flag.
Visual-verified by user: cellar descent works, 2nd-floor walking works,
no regression on Phase 2 single-floor cottage scenarios.
**Original description:** ... [keep the original text below]
(Replace the <COMMIT_SHA_*> placeholders with the actual SHAs from git log --oneline -5 after Tasks 1–4 land.)
- Step 5.6: Commit docs + roadmap update
git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
git commit -m "$(cat <<'EOF'
docs: ship Indoor walkable-plane BSP port — close #83
Roadmap shipped-table row + ISSUES #83 resolution block.
Visual-verified by user at <buildings tested>: cellar descent works,
2nd-floor walking works, no regression on Phase 2 single-floor cottage
scenarios.
Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
EOF
)"
Self-Review
1. Spec coverage: Re-checked spec §3 (Architecture), §4 (Implementation surface), §5 (Diagnostics), §6 (Testing), §8 (Acceptance criteria). Coverage map:
| Spec section | Plan task |
|---|---|
§3 / §4.1 — extend FindWalkableInternal + add FindWalkableSphere |
Task 1 + Task 2 |
§4.2 — refactor TryFindIndoorWalkablePlane body |
Task 3 (steps 3.4) |
§4.3 — INDOOR_WALKABLE_PROBE_DISTANCE constant, sphereRadius parameter |
Task 3 (steps 3.4, 3.6) |
| §4.4 — callsite update | Task 3 (step 3.6) |
§4.5 — delete PointInPolygonXY |
Task 3 (step 3.5) |
§5 — [indoor-walkable] probe line |
Task 4 |
| §6.1 — 4 unit tests + 1 integration test | Task 2 (steps 2.1, 2.5) + Task 3 (step 3.2) |
| §6.3 — visual verification scenarios | Task 5 (steps 5.2, 5.3) |
| §8 — acceptance criteria (build/test/visual/roadmap/ISSUES) | Task 5 (all steps) |
All sections covered.
2. Placeholder scan: No "TBD", "TODO", or vague "add error handling" instructions. Every code step has full code; every command has expected output. The <COMMIT_SHA_*> placeholders in Task 5.5 are documented as "replace after Tasks 1–4 land" — that is concrete, not vague.
3. Type consistency:
FindWalkableInternalsignature:ref ResolvedPolygon? hitPoly, ref ushort hitPolyId, ref bool changed— consistent across all 6 call sites (4 internal recursions in Task 1.1, StepSphereDown in Task 1.2, Path 4 in Task 1.3, FindWalkableSphere in Task 2.3).FindWalkableSpheresignature:(root, resolved, transition, sphere, probeDistance, up, out hitPoly, out hitPolyId, out adjustedCenter)— consistent in Task 2.3 implementation, Task 2.1/2.5 tests (calls), and Task 3.4 caller.TryFindIndoorWalkablePlanesignature:(cellPhysics, localFootCenter, sphereRadius, out worldPlane, out worldVertices, out hitPolyId)— consistent in Task 3.2 test, Task 3.4 implementation, Task 3.6 callsite, Task 4.1 callsite.
4. Bite-sized tasks: Each step is one action (write a test, run a command, modify a block of code, commit). Steps 2.1/2.5 are larger (full test bodies) but each defines exactly one test method per code block. No batched edits across files in a single step.
No spec requirements missed; no contradictions found. Plan committed below.