# Indoor Walkable-Plane BSP Port — Design

**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
**Scope:** Replace `TryFindIndoorWalkablePlane`'s linear first-match scan with a thin wrapper over the existing retail-faithful BSP walkable-finder (`BSPQuery.FindWalkableInternal`). Restores closest-walkable-poly-along-up-vector semantics for indoor cells with multiple floors at different Z (cellars, 2nd floors, balconies).
**Predecessor:** Indoor walking Phase 2 — Portal-based cell tracking ([`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md)) shipped 2026-05-19. Phase 2's commit 6 (`eb0f772`) introduced `TryFindIndoorWalkablePlane` as a stop-gap walkable-plane synthesis when the indoor BSP returns OK; it uses a linear first-match XY scan that ignores Z, which collapses to wrong-floor selection on any multi-Z indoor geometry.
**Retail oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
- `BSPLEAF::find_walkable` at 326793 — the leaf-level walkable test.
- `BSPNODE::find_walkable` at 326211 — the BSP-traversal internal-node version.
- `CPolygon::walkable_hits_sphere` at 323006 — `N·up > walkableAllowance` AND XY-overlap test.
- `CPolygon::adjust_sphere_to_plane` at 322032 — slides sphere along the movement vector to rest on the polygon's plane; `walk_interp` is ratcheted down to the earliest hit.

---

## 1. What we know

**The retail walkable-finder is already ported.** [`BSPQuery.FindWalkableInternal`](../../../src/AcDream.Core/Physics/BSPQuery.cs:647) implements the full retail algorithm — BSP traversal, leaf-level polygon scan, `WalkableHitsSphere` + `AdjustSphereToPlane`. It is used by:
- `BSPQuery.StepSphereDown` (Path 3) at [BSPQuery.cs:1107](../../../src/AcDream.Core/Physics/BSPQuery.cs:1107) — the step-down branch invoked from `DoStepDown` → `TransitionalInsert(5)`.
- `BSPQuery.FindCollisions` Path 4 (Collide) at [BSPQuery.cs:1492](../../../src/AcDream.Core/Physics/BSPQuery.cs:1492) — landing on a surface after free-fall.

**The new helper that doesn't use it.** `Transition.TryFindIndoorWalkablePlane` at [TransitionTypes.cs:1192](../../../src/AcDream.Core/Physics/TransitionTypes.cs:1192) was added in Phase 2 commit 6 (`eb0f772`) to synthesize an indoor walkable plane in the case where the indoor BSP returns OK (no wall collision). Its body iterates `cellPhysics.Resolved` in dictionary order, returns the first polygon with `Normal.Z >= 0.6664` whose XY contains the foot. There is no Z-proximity test.

**Visual evidence (user-reported, 2026-05-19, post-Phase-2-merge):**
- Walking UP stairs in houses works (step_up → DoStepDown routes through Path 3 = `FindWalkableInternal`, which already picks the closest walkable).
- Walking DOWN into cellars is broken (player can't descend; standing on upper floor with cellar floor beneath → linear scan picks upper floor → ValidateWalkable can't drop player below it).
- Walking on 2nd floor is broken (linear scan picks 1st-floor poly → player snaps to 1st floor or is reported airborne above 2nd floor).
- "Invisible obstacles at certain spots" — suspected cascade effect of wrong-Z ContactPlane (resolver flags body as airborne / submerged → next-frame collision misroutes). Not a separate root cause hypothesis.

---

## 2. Goal

Route indoor walkable-plane synthesis through `BSPQuery.FindWalkableInternal` so the synthesized ContactPlane is the polygon the player is actually standing on (closest walkable surface below the foot, along the local up vector), not the first walkable polygon in dictionary order.

**Expected to fix:**
- Cellar descent (player can step off the upper floor and descend through the stairwell onto the cellar floor).
- 2nd-floor walking (player stays on the upper floor without snapping back to ground level).

**Possibly fixes (cascade):**
- "Invisible obstacles at certain spots" — if this is downstream of wrong-Z ContactPlane causing the resolver to misclassify the body's grounded/airborne state. If after the fix these persist, that's a separate phase.

**Possibly fixes (related):**
- ISSUES.md #88 (indoor static objects vibrate). If the per-frame ContactPlane Z flickers between two overlapping floors, dependent state may re-fire (`EntityScriptActivator` OnCreate/OnRemove, per-part transforms). Not the primary target; user re-verifies #88 after the fix lands.

**Out of scope:**
- Outdoor terrain walkable selection (`PhysicsEngine.SampleTerrainWalkable`) — unchanged.
- BSP collision dispatcher (`BSPQuery.FindCollisions` Paths 1–6) — unchanged.
- Step-up / step-down flow (`DoStepUp` / `DoStepDown`) — unchanged. Stairs going UP already work; we don't risk regressing that path.
- Cell transit / portal traversal (Phase 2 `CellTransit`) — unchanged.
- Building-shell outdoor→indoor entry (`CheckBuildingTransit`) — unchanged.
- Issue #89 (`SphereIntersectsCellBsp` retail-faithful port) — unchanged; separate phase.

---

## 3. Architecture

**One change, two callers.**

1. **New public entry point in `BSPQuery`** — a thin wrapper over the existing private `FindWalkableInternal`.

2. **Refactor of `Transition.TryFindIndoorWalkablePlane`** — replace the linear scan body with a call to the new entry point. Public signature unchanged.

3. **Removal of `Transition.PointInPolygonXY`** — only callsite was the linear-scan body; becomes dead code.

```
Movement tick → resolver substep
        │
        ▼
Transition.FindEnvCollisions (TransitionTypes.cs:1262)        [UNCHANGED]
        │
        ├─ Transform foot sphere to cell-local space          [UNCHANGED]
        ├─ BSPQuery.FindCollisions(cellPhysics.BSP, ...)      [UNCHANGED]
        │   └─ Wall collision: returns Slid / Adjusted / Collided
        │       → early-return; never reaches walkable synthesis
        ├─ If result != OK → early-return                     [UNCHANGED]
        └─ If result == OK:
              ▼
        Transition.TryFindIndoorWalkablePlane                  [REFACTORED]
              │
              ├─ Save path.WalkableAllowance
              ├─ Set path.WalkableAllowance = PhysicsGlobals.FloorZ
              ├─ Build localMovement = -localUp * PROBE_DISTANCE
              ├─ Build localSphere from localFootCenter + radius
              │
              ├─ ╔══════════════════════════════════════════════╗
              │  ║ BSPQuery.FindWalkableSphere                  ║ [NEW WRAPPER]
              │  ║   wraps the existing FindWalkableInternal    ║
              │  ║   (BSPNode.find_walkable + BSPLeaf.find_walkable
              │  ║   port — retail at acclient_2013_pseudo_c.txt
              │  ║   326211 + 326793)                           ║
              │  ╚══════════════════════════════════════════════╝
              │              │
              │              ▼
              │      ResolvedPolygon? hitPoly + Vector3 adjustedCenter
              │
              ├─ Restore path.WalkableAllowance (try/finally)
              ├─ If hitPoly == null → return false (caller falls
              │     through to outdoor-terrain backstop, unchanged)
              └─ Transform hitPoly plane + vertices to world space
                    (existing helper logic, kept verbatim)
                    → return true with (worldPlane, worldVertices, hitPolyId)
              ▼
        Transition.ValidateWalkable(worldPlane, ...)            [UNCHANGED]
```

The refactor is surgical: one helper body changes, one wrapper is added, one dead helper deleted. No call-site changes. No new types. No new flags. No new env vars.

---

## 4. Implementation surface

### 4.1 `BSPQuery.FindWalkableSphere` (new public entry point) + small extension to `FindWalkableInternal`

`ResolvedPolygon` does not carry its own id — the polyId is the `Dictionary<ushort, ResolvedPolygon>` key. `FindWalkableInternal` iterates `foreach (ushort polyId in node.Polygons)` in the leaf branch (BSPQuery.cs:665), so the key IS available internally — we just need to expose it. Two coupled changes:

**(a) Extend `FindWalkableInternal` signature** with a `ref ushort hitPolyId` param. Update the leaf branch's write to set both `hitPoly` AND `hitPolyId` together. Update all internal recursion sites (BSPQuery.cs:688, :695, :701, :703) to thread the new ref. Update the two existing callers (`StepSphereDown` at :1107 and Path 4 at :1492) to declare a local `ushort _` if they don't care about the id (existing behavior preserved — they only use `hitPoly`).

**(b) Add the public wrapper.** Place in `src/AcDream.Core/Physics/BSPQuery.cs` adjacent to `StepSphereDown` (around line 1085) since they share the same call shape into `FindWalkableInternal`.

```csharp
/// <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
/// <see cref="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
/// (<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. Cheapest pattern: try/finally with
/// save→set→call→restore.
/// </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,
    DatReaderWriter.Types.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;

    FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
                          movement, up, ref hitPoly, ref polyId, ref changed);

    if (changed && hitPoly is not null)
    {
        adjustedCenter = validPos.Center;
        hitPolyId = polyId;
        return true;
    }

    hitPoly = null;
    hitPolyId = 0;
    return false;
}
```

**Notes on the wrapper:**
- Pure, no side effects on call args other than `out` params.
- `FindWalkableInternal` mutates `validPos` and `path.WalkInterp` (via `AdjustSphereToPlane`); the wrapper isolates `validPos` to a local. `path.WalkInterp` mutation is intentional and matches retail's `walk_interp` ratcheting — caller's responsibility to save/restore if needed.
- No new dependencies. All types already in scope.

### 4.2 `Transition.TryFindIndoorWalkablePlane` (refactored body + extended signature)

File: `src/AcDream.Core/Physics/TransitionTypes.cs` (around line 1192). Signature gains a `sphereRadius` parameter (rationale §4.3):

```csharp
internal bool TryFindIndoorWalkablePlane(
    CellPhysics cellPhysics,
    Vector3 localFootCenter,
    float sphereRadius,
    out System.Numerics.Plane worldPlane,
    out Vector3[] worldVertices,
    out uint hitPolyId)
```

The helper changes from `internal static` to `internal` (instance method) so it can access `this.SpherePath` for the WalkableAllowance save/restore and pass `this` (Transition) to `BSPQuery.FindWalkableSphere`. The single callsite at TransitionTypes.cs:1358 is already inside a Transition instance method (`FindEnvCollisions`).

Body:

```csharp
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: the BSP walkable test consumes
// path.WalkableAllowance (CPolygon::walkable_hits_sphere reads this field,
// 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.
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,    // see §4.3
        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. This block is
// kept verbatim from the existing TryFindIndoorWalkablePlane implementation —
// the world-transform math is unchanged.
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;
```

### 4.3 Constants and parameter rationale

| Symbol | Kind | Value / source | Rationale |
|---|---|---|---|
| `INDOOR_WALKABLE_PROBE_DISTANCE` | `private const float` in `Transition` | `0.5f` | 50 cm. Larger than the +0.02f cell-origin Z-bump (25× headroom). Larger than any realistic step riser (~20 cm). Smaller than a full cell height (~3 m) so we don't reach through a thin floor into the cell above/below. |
| `sphereRadius` | method parameter | sourced from `sp.GlobalSphere[0].Radius` at the `FindEnvCollisions` call site (already bound at TransitionTypes.cs:1268) | The foot sphere radius is per-entity (player ≠ creature). Hardcoding would be wrong; threading the parameter is one line at the callsite. |

### 4.4 Callsite update

At TransitionTypes.cs:1358, the existing call:

```csharp
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
        out var indoorPlane,
        out var indoorVertices,
        out uint _))
```

becomes:

```csharp
if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
        out var indoorPlane,
        out var indoorVertices,
        out uint _))
```

`sphereRadius` is already in scope from line 1268.

### 4.5 Delete `Transition.PointInPolygonXY`

At TransitionTypes.cs:1238. Only call site was the deleted linear-scan loop. Remove the method entirely.

---

## 5. Diagnostics

**Extend existing `[indoor-bsp]` probe surface, no new probe.**

The existing `[indoor-bsp]` line in `FindEnvCollisions` (at TransitionTypes.cs:1334) already prints per-call BSP-collision state. Add a sibling line `[indoor-walkable]` printed when the OK-result branch calls `TryFindIndoorWalkablePlane`, gated on the same `PhysicsDiagnostics.ProbeIndoorBspEnabled` flag (no new flag).

Print format:
```
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=HIT poly=0x0042 wn=(0.000,0.000,1.000) wD=-2.75 dz=+0.03
```
or
```
[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=MISS
```

Where:
- `wpos` — world-space foot center.
- `wn`/`wD` — world-space plane normal + D (on HIT only).
- `dz` — signed Z gap between foot center and plane (positive = player above plane, negative = below).

Runtime-toggleable via the existing DebugPanel "Indoor BSP probe" checkbox. Cost when probe disabled: a single bool check (early-return).

For cellar descent, expect the trace to flip from "always picks upper floor (dz≈+3.0)" pre-fix to "picks cellar floor below (dz≈+0.03)" post-fix.

---

## 6. Testing

### 6.1 Unit tests (new)

Location: `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` (adjacent to existing BSPQuery tests).

**Test 1: `FindWalkableSphere_TwoOverlappingFloors_PicksClosestBelowFoot`**

Synthetic mini-BSP with two horizontal walkable polys at Z=0 and Z=3, both passing XY through (0,0). Foot sphere centered at (0,0,1) radius 0.48, probe 0.5. Assert returned poly is the Z=0 one; assert `adjustedCenter.Z` ≈ 0.48.

**Test 2: `FindWalkableSphere_TwoOverlappingFloors_FootAbove_PicksUpper`**

Same mini-BSP. Foot at (0,0,3.6), probe 0.5. Assert returned poly is the Z=3 one; assert `adjustedCenter.Z` ≈ 3.48.

**Test 3: `FindWalkableSphere_NoWalkableInRange_ReturnsFalse`**

Two polys at Z=0 and Z=3. Foot at (0,0,5), probe 0.5. Sphere is more than `probe + radius` above the Z=3 plane; nothing in range. Assert returns false; assert `adjustedCenter == sphere.Origin`.

**Test 4: `FindWalkableSphere_SteepPolyRejected`**

Mini-BSP with one poly whose normal Z = 0.5 (52° slope, below FloorZ=0.6664). Foot above it. Caller sets `WalkableAllowance = FloorZ`. Assert returns false (poly rejected as too steep).

**Test 5: `TryFindIndoorWalkablePlane_RoutesThroughBSPQuery_PreservesAllowance`**

Integration test with a real `CellPhysics` fixture (two-floor cell). Set `path.WalkableAllowance` to a sentinel value (e.g. 0.42f). Call `TryFindIndoorWalkablePlane`. Assert returned plane corresponds to the closest floor below the foot. Assert `path.WalkableAllowance == 0.42f` after the call (save/restore worked).

### 6.2 Existing test baselines

- `dotnet build` clean.
- `dotnet test` shows the same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). No new failures.
- Phase 2 indoor-walking conformance unchanged (single-floor cottage cell remains correct — the new closest-below algorithm degenerates to the existing first-match behavior when only one walkable poly exists).

### 6.3 Visual verification (the real acceptance test)

User-driven, the only "milestone" verification the project uses (per CLAUDE.md "milestones are textual events").

**Required scenarios:**

| # | Scenario | Pre-fix behavior | Acceptance |
|---|---|---|---|
| 1 | Walk into Holtburg cottage, walk around single-floor interior | Works (Phase 2) | Still works — no regression |
| 2 | Walk between cottage rooms via doorways | Works (Phase 2) | Still works — no regression |
| 3 | Walk back outside through cottage door | Works (Phase 2) | Still works — no regression |
| 4 | Find any building with a cellar entry, walk to and descend the stairs | Stuck / bounces at top of stairs | Smooth descent onto cellar floor |
| 5 | Find any 2-story building, climb stairs to 2nd floor, walk around upper floor | Snaps back to 1st floor or "invisible obstacles" | Stays on 2nd floor, free movement |
| 6 | Walk near previously-reported "invisible obstacle" spots | Hits invisible wall | (Hypothesis check) — invisible obstacles gone if cascade theory correct |
| 7 | (Optional) Observe bookshelves/furnaces #88 vibration | Visible jitter | (Hypothesis check) — jitter reduced if cascade theory correct |

**If scenario 4 or 5 still fails after this lands, that's an indicator the diagnosis is incomplete — file a follow-up phase.** If scenario 6 still fails but 4/5 work, that's a separate bug (probably real BSP-classification issue, not walkable-plane).

---

## 7. Edge cases

Handled by the existing `FindWalkableInternal` (not new logic):
- Sphere doesn't intersect any BSP node → no recursion, `changed` stays false, miss path runs.
- BSP root is null → wrapper returns false before recursion.
- Multiple walkable polys in the same leaf → loop visits all, `AdjustSphereToPlane` ratchets `walk_interp` down to the closest hit (retail-faithful behavior, see acclient_2013_pseudo_c.txt:322055).
- `WalkableAllowance > 1.0` (illegal) → `WalkableHitsSphere` returns false for every poly, miss path runs (defensive).

Introduced by the wrapper:
- `WalkableAllowance` save/restore wrapped in try/finally so a thrown exception inside `FindWalkableInternal` doesn't leak modified state to the resolver.

Cell-state edge cases (unchanged from Phase 2):
- Cell has no walkable polys (only walls + ceiling) → wrapper returns false → `FindEnvCollisions` falls through to outdoor-terrain backstop (existing behavior at TransitionTypes.cs:1372).
- Cell origin Z-bump (+0.02f) interaction — probe distance 0.5f is 25× the bump, so the bump is noise within the probe range.

---

## 8. Acceptance criteria

1. `dotnet build -c Debug` clean.
2. `dotnet test` shows the same 8 pre-existing failures (no new failures from this work).
3. New unit tests 1–5 (§6.1) pass.
4. Visual verification scenarios 1–5 (§6.3) all pass per user testing.
5. Visual verification scenarios 6 and 7 reported as PASS/FAIL by user (cascade hypothesis confirmation, not gating).
6. Roadmap shipped table updated.
7. ISSUES.md #83 closed (Walking up stairs broken — bug now scoped to "walking DOWN in multi-floor cells").
8. Phase memory updated if a durable lesson surfaces during implementation.

---

## 9. References

**Retail oracle:**
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326211` — `BSPNODE::find_walkable`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` — `BSPLEAF::find_walkable`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323006` — `CPolygon::walkable_hits_sphere`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:322032` — `CPolygon::adjust_sphere_to_plane`
- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` — `BSPTREE::step_sphere_up` (related; uses find_walkable via step_sphere_down)

**acdream code:**
- `src/AcDream.Core/Physics/BSPQuery.cs:647` — `FindWalkableInternal` (existing retail port)
- `src/AcDream.Core/Physics/BSPQuery.cs:1085` — `StepSphereDown` (existing caller of FindWalkableInternal)
- `src/AcDream.Core/Physics/TransitionTypes.cs:1192` — `TryFindIndoorWalkablePlane` (the helper being refactored)
- `src/AcDream.Core/Physics/TransitionTypes.cs:1262` — `FindEnvCollisions` (the single callsite)
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — diagnostic flag pattern (existing `ProbeIndoorBspEnabled`)

**Predecessor specs:**
- [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](2026-05-19-indoor-walking-phase1-bsp-cluster-design.md) — Phase 1 (cell-BSP wall collision).
- [`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md) — Phase 2 (portal cell tracking).

**Phase 2 ship handoff:**
- [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md) — context for the `TryFindIndoorWalkablePlane` introduction in commit `eb0f772`.

**Issue tracking:**
- `docs/ISSUES.md` #83 (Walking up stairs broken) — primary scope. Title is misleading per user: actual symptom is walking DOWN in multi-floor cells (cellars, descending stairs).
- `docs/ISSUES.md` #88 (Indoor static objects vibrate) — possibly downstream; user re-verifies after fix lands.
- `docs/ISSUES.md` #89 (Port `SphereIntersectsCellBsp`) — explicitly out of scope; separate phase.
