# 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<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 `StepSphereDown` caller**

Change `BSPQuery.cs:1085-1128`:

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

```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)
    // =========================================================================

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

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

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

```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);
    }

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

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

```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 · <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**

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