diff --git a/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md b/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md new file mode 100644 index 0000000..b9010fc --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md @@ -0,0 +1,1249 @@ +# 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](../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 `FindWalkableInternal` signature and body** + +Change `BSPQuery.cs:647-705`: + +```csharp +private static void FindWalkableInternal( + PhysicsBSPNode? node, + Dictionary 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 `StepSphereDown` caller** + +Change `BSPQuery.cs:1085-1128`: + +```csharp +private static TransitionState StepSphereDown( + PhysicsBSPNode root, + Dictionary 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: + +```csharp + 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** + +```bash +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 to `StepSphereDown` (~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): + +```csharp + // ========================================================================= + // FindWalkableSphere — indoor walkable-plane finder (spec 2026-05-19) + // ========================================================================= + + /// + /// 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. + /// + private static (PhysicsBSPNode root, Dictionary 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 + { + [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); + } + + /// + /// Build a Transition with WalkableAllowance set to FloorZ — what the + /// indoor walkable-plane synthesis uses. + /// + 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): + +```csharp + // ------------------------------------------------------------------------- + // find_walkable_sphere — "stand here, find my contact plane" + // Indoor walkable-plane synthesis entry point (Phase 2 follow-up 2026-05-19). + // ------------------------------------------------------------------------- + + /// + /// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf + /// find_walkable BSP traversal. Probes downward by + /// along and returns the closest walkable polygon the + /// sphere would rest on, with the sphere's center adjusted to lie on that plane. + /// + /// + /// Wraps the existing private — 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). + /// + /// + /// + /// Intended call site: indoor walkable-plane synthesis in + /// Transition.TryFindIndoorWalkablePlane 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 + /// () and does not use this. + /// + /// + /// + /// The caller is responsible for setting transition.SpherePath.WalkableAllowance + /// to the desired walkability threshold (typically ) + /// before calling, and restoring it after. + /// + /// + /// Root of the cell's PhysicsBSP. + /// Pre-resolved polygon dictionary from PhysicsDataCache. + /// Current transition (read for WalkableAllowance / walk_interp). + /// Foot sphere in cell-local space. + /// Downward probe distance in meters. Typical: 0.5f. + /// Up vector in cell-local space (typically Vector3.UnitZ). + /// Output: the walkable polygon found, or null on miss. + /// Output: polygon id (dictionary key) of the hit. Zero on miss. + /// + /// Output: sphere center adjusted onto the polygon plane. Equal to input + /// sphere.Origin on miss. + /// + /// True if a walkable polygon was found; false otherwise. + public static bool FindWalkableSphere( + PhysicsBSPNode? root, + Dictionary 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: + +```csharp + [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 + { + [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** + +```bash +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: + +```csharp +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: + +```csharp + [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); + } + + /// + /// Build a minimal CellPhysics with two horizontal walkable polygons at + /// local Z=lowerZ and Z=upperZ. Identity world transform so world == local. + /// + 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 + { + [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: + +```csharp + /// + /// Synthesize the indoor walkable contact plane for the player's current + /// position when the cell BSP returns OK (no wall collision). + /// + /// + /// Routes through the retail-faithful BSP walkable-finder + /// () — 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). + /// + /// + /// + /// Returns false 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). + /// + /// + /// + /// 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). + /// + /// + 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(); + 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; + } + + /// + /// Downward probe distance used by + /// 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. + /// + private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f; +``` + +**Notes on the change:** +- Method went from `internal static` to `internal` (instance method) — `this.SpherePath` access requires it. The single callsite at line 1358 is already inside a `Transition` instance method. +- Removed `static` keyword. +- Added `float sphereRadius` parameter. +- Replaced the linear scan body and the `PointInPolygonXY` call with a `BSPQuery.FindWalkableSphere` call wrapped in try/finally for allowance save/restore. + +- [ ] **Step 3.5: Delete `PointInPolygonXY` helper** + +Remove `src/AcDream.Core/Physics/TransitionTypes.cs:1234-1253` (the entire `PointInPolygonXY` method including its leading `` 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: + +```csharp + if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, + out var indoorPlane, + out var indoorVertices, + out uint _)) +``` + +to: + +```csharp + 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** + +```bash +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 the `TryFindIndoorWalkablePlane` callsite inside `FindEnvCollisions` (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): + +```csharp + 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: + +```csharp + 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** + +```bash +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=1` probe to capture `[indoor-walkable]` lines, +> connect, walk to a building, and test: +> +> 1. Walk into Holtburg cottage, walk around single-floor interior. **Acceptance: no regression — still walks freely.** +> 2. Walk between cottage rooms via doorways. **Acceptance: no regression.** +> 3. Walk back outside through cottage door. **Acceptance: no regression.** +> 4. **Find any building with a cellar entry; descend the stairs.** Pre-fix: stuck/bounces at top. Acceptance: smooth descent onto cellar floor. +> 5. **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. +> 6. (Observational, not gating) Walk near previously-reported "invisible obstacle" spots — report PASS if gone, FAIL if persist. +> 7. (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: + +```markdown +## #83 — [DONE 2026-05-19 · ] Walking up stairs broken (actual symptom: walking DOWN in multi-floor cells) + +**Status:** DONE +**Closed:** 2026-05-19 +**Commits:** , , , +**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 `` placeholders with the actual SHAs from `git log --oneline -5` after Tasks 1–4 land.) + +- [ ] **Step 5.6: Commit docs + roadmap update** + +```bash +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 : 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 `` placeholders in Task 5.5 are documented as "replace after Tasks 1–4 land" — that is concrete, not vague. + +**3. Type consistency:** +- `FindWalkableInternal` signature: `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). +- `FindWalkableSphere` signature: `(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. +- `TryFindIndoorWalkablePlane` signature: `(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.