# Indoor Walking Phase 1 — BSP Cluster (#84 / #85 / #86) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Surface the root causes for ISSUES.md #84 (blocked by air indoors), #85 (pass through walls outside→in), and #86 (click selection penetrates walls) via a single diagnostic-driven capture session, then ship one surgical fix commit per issue.

**Architecture:** Add an `[indoor-bsp]` probe to `TransitionTypes.FindEnvCollisions`' cell-BSP branch (the indoor-collision code path already exists at lines 1188-1241 but emits no diagnostics). Capture one Holtburg Inn walkaround session that exercises all three issues. Read the log, pin each root cause to a specific code site, ship a separate surgical commit per issue. #86 has no probe dependency — its cause is already pinned by code-reading (WorldPicker.Pick has no cell-BSP test) — so its fix is structural.

**Tech Stack:** C# / .NET 10, xUnit, Silk.NET, Möller-Trumbore ray-triangle intersection. Uses existing physics + selection types. Probe writes to `Console.WriteLine` per the established `[indoor-*]` / `[resolve]` / `[cell-transit]` convention.

**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](../specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md)

---

## File Structure

| File | Action | Responsibility |
|---|---|---|
| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` | modify | Add `ProbeIndoorBspEnabled` static property. |
| `src/AcDream.Core/Physics/BSPQuery.cs` | modify | Have all 8 `LastBspHitPoly = hitPoly` write sites fire when `ProbeIndoorBspEnabled` is true (currently only fires for `ProbeBuildingEnabled`). |
| `src/AcDream.Core/Physics/TransitionTypes.cs` | modify | Emit `[indoor-bsp]` log around the cell-BSP `FindCollisions` call at line 1222. |
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | modify | Add `ProbeIndoorBsp` runtime mirror property. |
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | modify | Add checkbox row beneath the existing indoor-render probes. |
| `src/AcDream.Core/Selection/CellBspRayOccluder.cs` | create | Pure Möller-Trumbore ray-triangle test against a set of `CellPhysics`. Returns nearest-wall `t` along ray. |
| `src/AcDream.Core/Selection/WorldPicker.cs` | modify | Both `Pick` overloads accept an optional `cellOccluder` callback. Production callers pass it; tests can pass `null`. |
| `src/AcDream.App/Rendering/GameWindow.cs` | modify | Wire `CellBspRayOccluder.NearestWallT` into the screen-rect `Pick` call at line 9134. |
| `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs` | create | Direct unit tests for Möller-Trumbore semantics. |
| `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` | create | Integration test: synthetic wall poly between ray origin and entity → no hit. |
| `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` | modify | Add a parity test for the new `ProbeIndoorBsp` mirror. |
| `docs/ISSUES.md` | modify | Move #84/#85/#86 to "Recently closed" with commit SHAs. |
| `docs/plans/2026-04-11-roadmap.md` | modify | Add shipped-table entry for the phase. |

---

## Probe pre-work: code-shape facts

These facts are referenced in many tasks below. Read them once.

**Fact 1.** `PhysicsDiagnostics.cs` already has six toggles (`ProbeResolveEnabled`, `ProbeCellEnabled`, `ProbeBuildingEnabled`, `ProbeAutoWalkEnabled`, `ProbeUseabilityFallbackEnabled`, `DumpSteepRoofEnabled`) plus the `LastBspHitPoly` diagnostic side-channel. Pattern is `public static bool Foo { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_FOO") == "1";`.

**Fact 2.** `BSPQuery.cs` has 8 sites that write `PhysicsDiagnostics.LastBspHitPoly = hitPoly;`, each gated by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`. The sites are at lines 1219, 1232, 1239, 1555, 1589, 1673, 1683, 1713, 1722. (Verify line numbers before editing — file evolves.)

**Fact 3.** `TransitionTypes.FindEnvCollisions` cell-BSP branch lives at TransitionTypes.cs:1188-1241. The `BSPQuery.FindCollisions` call is at line 1222. Pre-call, the engine has `cellPhysics`, `localSphere`, `localCurrCenter`, `sp.CheckCellId`, `footCenter`. Post-call, `cellState` carries the outcome and `PhysicsDiagnostics.LastBspHitPoly` carries the hit poly (if our probe and the indoor flag fire together).

**Fact 4.** Only ONE production `WorldPicker.Pick` call exists, at GameWindow.cs:9134 — the screen-rect overload. The legacy ray-sphere overload at WorldPicker.cs:88-160 is test-only.

**Fact 5.** Cell physics caching site is `_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)` at GameWindow.cs:5384 — applied to ALL loaded EnvCells. Access via `_physicsDataCache.GetCellStruct(envCellId)` returning a `CellPhysics?` with `BSP`, `Resolved`, `WorldTransform`, `InverseWorldTransform`.

**Fact 6.** `CellPhysics.Resolved` is a `Dictionary<ushort, ResolvedPolygon>` — keys are poly ids, values include `Vertices` (already-resolved world-positions, but they're in LOCAL space — multiply by `WorldTransform` to get world), `Plane`, `NumPoints`, `SidesType`. (Confirm by reading `PhysicsDataCache.ResolvePolygons` lines 155-204.)

**Fact 7.** Three loaded sets the picker needs:
- `_physicsDataCache.GetCellStruct(id)` — looks up one cell's BSP.
- The set of currently-loaded EnvCell ids — enumerate via `_cellVisibility._cellLookup` (if that's accessible from GameWindow) or by iterating `_pendingCellMeshes.Keys` / a similar field. **Confirm during Task 10** which collection is the authoritative list of loaded EnvCells in GameWindow.

---

## Task 1: Add `PhysicsDiagnostics.ProbeIndoorBspEnabled`

**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`

- [ ] **Step 1: Add the new toggle property at the bottom of the existing toggle list**

In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, immediately before the closing `}` of the class (after `DumpSteepRoofEnabled` at line 168), add:

```csharp
    /// <summary>
    /// Indoor walking Phase 1 (2026-05-19). When true, emits one
    /// <c>[indoor-bsp]</c> line per <see cref="BSPQuery.FindCollisions"/>
    /// call made from <see cref="Transition.FindEnvCollisions"/>'s indoor
    /// cell-BSP branch. Captures the cell id, sphere local position,
    /// resulting <see cref="TransitionState"/>, and the hit poly's id,
    /// local-normal, and side-type — pinpoints why indoor collision
    /// returns spurious collisions (#84) and helps cross-check the
    /// outdoor-in approach path (#85).
    ///
    /// <para>
    /// While true, this also un-gates the diagnostic
    /// <see cref="LastBspHitPoly"/> side-channel inside
    /// <see cref="BSPQuery"/> — see the OR'd condition at every poly
    /// write site. Zero-cost when off.
    /// </para>
    ///
    /// <para>
    /// Initial state from <c>ACDREAM_PROBE_INDOOR_BSP=1</c>.
    /// Runtime-toggleable via DebugPanel.
    /// </para>
    ///
    /// <para>
    /// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md</c>.
    /// </para>
    /// </summary>
    public static bool ProbeIndoorBspEnabled { get; set; } =
        Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
```

- [ ] **Step 2: Verify build**

Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
Expected: Build succeeds, 0 errors.

- [ ] **Step 3: No commit yet** — bundle with Task 2 and Task 3 into one probe-feature commit.

---

## Task 2: OR `ProbeIndoorBspEnabled` into BSPQuery's `LastBspHitPoly` write sites

**Files:**
- Modify: `src/AcDream.Core/Physics/BSPQuery.cs`

**Why:** `BSPQuery` currently writes `PhysicsDiagnostics.LastBspHitPoly` only when `ProbeBuildingEnabled` is true. The indoor probe needs the same side-channel; un-gate it for either flag.

- [ ] **Step 1: Find every write site**

Run: `rg -n "PhysicsDiagnostics.LastBspHitPoly = hitPoly" src/AcDream.Core/Physics/BSPQuery.cs`

Expected: 8 lines, each immediately preceded by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`.

- [ ] **Step 2: Replace the gate at each site**

For each of the 8 occurrences, edit the gate from:
```csharp
if (PhysicsDiagnostics.ProbeBuildingEnabled)
    PhysicsDiagnostics.LastBspHitPoly = hitPoly;
```
to:
```csharp
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
    PhysicsDiagnostics.LastBspHitPoly = hitPoly;
```

(The local variable name varies: `hitPoly`, `hitPoly0`, `hitPoly1`. Preserve the local name at each site.)

Use the Edit tool one site at a time. If two sites have identical surrounding text, use `replace_all: true` on the gate-only string `if (PhysicsDiagnostics.ProbeBuildingEnabled)` since the OR transformation is identical for every site that this exact gate immediately precedes a `LastBspHitPoly` write. But verify no other call uses the same gate before doing replace_all.

Confirm via:
`rg -n "PhysicsDiagnostics.ProbeBuildingEnabled\b" src/AcDream.Core/Physics/BSPQuery.cs`
Expected: every match is followed on the next line by a `LastBspHitPoly` write.

- [ ] **Step 3: Verify build**

Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
Expected: Build succeeds.

- [ ] **Step 4: Run BSPQuery tests**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BSPQuery"`
Expected: all pass; behavior unchanged when both flags are false.

- [ ] **Step 5: No commit yet** — bundle with Task 1 and Task 3.

---

## Task 3: Emit `[indoor-bsp]` log line in `FindEnvCollisions`

**Files:**
- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs`

**Why:** One log line per cell-BSP collision query, with enough fields to diagnose poly Z-bump misalignment (#84) and out-of-cell asymmetry (#85). Bracketed prefix matches the existing `[indoor-*]` convention.

- [ ] **Step 1: Locate the insertion site**

Verify the cell branch is still at TransitionTypes.cs:1188-1241 (line numbers may drift). Find the exact `var cellState = BSPQuery.FindCollisions(` line — currently 1222.

- [ ] **Step 2: Add `using System.Globalization;` if not already present**

Check the file header. If missing, add `using System.Globalization;` to the using block at the top of the file.

- [ ] **Step 3: Wrap the FindCollisions call with the probe**

Replace the existing block at lines ~1220-1239:

```csharp
                // Use the full 6-path BSP dispatcher for retail-faithful collision.
                // Use pre-resolved polygons (vertices+planes computed at cache time).
                var cellState = BSPQuery.FindCollisions(
                    cellPhysics.BSP.Root,
                    cellPhysics.Resolved,
                    this,
                    localSphere,
                    localSphere1,
                    localCurrCenter,
                    Vector3.UnitZ,  // local space Z is up
                    1.0f,           // scale = 1.0 for cell geometry
                    Quaternion.Identity,
                    engine);        // engine needed for Path 5 step-up

                if (cellState != TransitionState.OK)
                {
                    if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
                        ci.CollidedWithEnvironment = true;
                    return cellState;
                }
```

with:

```csharp
                // Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
                // side-channel before the call so a missed write (no collision)
                // is greppable as "poly=n/a" in the probe line below.
                if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
                    PhysicsDiagnostics.LastBspHitPoly = null;

                // Use the full 6-path BSP dispatcher for retail-faithful collision.
                // Use pre-resolved polygons (vertices+planes computed at cache time).
                var cellState = BSPQuery.FindCollisions(
                    cellPhysics.BSP.Root,
                    cellPhysics.Resolved,
                    this,
                    localSphere,
                    localSphere1,
                    localCurrCenter,
                    Vector3.UnitZ,  // local space Z is up
                    1.0f,           // scale = 1.0 for cell geometry
                    Quaternion.Identity,
                    engine);        // engine needed for Path 5 step-up

                if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
                {
                    var hit = PhysicsDiagnostics.LastBspHitPoly;
                    string polyDesc = hit is null
                        ? "poly=n/a"
                        : System.FormattableString.Invariant(
                            $"poly=0x{0:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}");
                    Console.WriteLine(System.FormattableString.Invariant(
                        $"[indoor-bsp] cell=0x{sp.CheckCellId:X8} " +
                        $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) " +
                        $"lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) " +
                        $"lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) " +
                        $"r={sphereRadius:F3} result={cellState} {polyDesc}"));
                }

                if (cellState != TransitionState.OK)
                {
                    if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
                        ci.CollidedWithEnvironment = true;
                    return cellState;
                }
```

**Note:** `LastBspHitPoly` is a `ResolvedPolygon?` (a struct or class — check). The format string assumes `hit.Plane.Normal` works. The id field is not stored on `ResolvedPolygon` directly (only the value lives in the dict). The probe substitutes `0x0000` for the id field — if poly-id is needed for triage, extend `ResolvedPolygon` to carry its key in a follow-up. Capture diagnoses don't usually need the id; the normal + side-type + local-z is enough.

- [ ] **Step 4: Verify build**

Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
Expected: Build succeeds.

- [ ] **Step 5: Run physics tests**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Transition"`
Expected: all pass — probe is zero-cost when off, and the toggle defaults off.

---

## Task 4: Add DebugVM mirror + DebugPanel checkbox

**Files:**
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`

- [ ] **Step 1: Add `ProbeIndoorBsp` property to DebugVM**

In `DebugVM.cs`, after the existing `ProbeIndoorCull` property (around line 346), add:

```csharp
    /// <summary>
    /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
    /// <c>PhysicsDiagnostics.ProbeIndoorBspEnabled</c> (env var
    /// <c>ACDREAM_PROBE_INDOOR_BSP</c>). Toggling here flips the
    /// <c>[indoor-bsp]</c> probe live — no relaunch required.
    /// Physics-side companion to the five render-side
    /// <c>ProbeIndoor*</c> mirrors directly above.
    /// </summary>
    public bool ProbeIndoorBsp
    {
        get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
        set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
    }
```

- [ ] **Step 2: Add checkbox row to DebugPanel**

In `DebugPanel.cs`, locate the existing indoor-probe block (the six `if (r.Checkbox("Indoor: ...", ...))` lines around 271-276).

After the last existing checkbox (`Indoor: cull`), add:

```csharp
        bool probeIndoorBsp    = _vm.ProbeIndoorBsp;
        if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp;
```

Also update the local-variable block above (around line 264-269) to include the new local. Insert under `bool probeIndoorCull   = _vm.ProbeIndoorCull;`:

```csharp
        // probeIndoorBsp added below (physics-side; not part of IndoorAll cascade)
```

(Placement comment for human readers — no functional impact.)

- [ ] **Step 3: Verify build**

Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj`
Expected: Build succeeds.

---

## Task 5: Add DebugVM parity test for `ProbeIndoorBsp`

**Files:**
- Modify: `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`

- [ ] **Step 1: Read the existing parity test for `ProbeBuilding`**

Run: `rg -n "ProbeBuilding" tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`

Find the test that verifies `vm.ProbeBuilding = true` flips `PhysicsDiagnostics.ProbeBuildingEnabled`. Use it as a template.

- [ ] **Step 2: Add the parity test**

Add a new test method after the existing `ProbeBuilding` test:

```csharp
[Fact]
public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics()
{
    var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled;
    try
    {
        var vm = MakeVm();   // Use the existing test factory in this file.

        vm.ProbeIndoorBsp = true;
        Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled);
        Assert.True(vm.ProbeIndoorBsp);

        vm.ProbeIndoorBsp = false;
        Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled);
        Assert.False(vm.ProbeIndoorBsp);
    }
    finally
    {
        PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled;
    }
}
```

**Note:** Check whether the test class has a `MakeVm()` helper. If not, look at how `ProbeBuilding_ForwardsToPhysicsDiagnostics` (or similar) constructs the VM and mirror that pattern.

- [ ] **Step 3: Verify the test runs and passes**

Run: `dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj --filter "FullyQualifiedName~ProbeIndoorBsp"`
Expected: 1 test passing.

- [ ] **Step 4: Full test sweep**

Run: `dotnet build` then `dotnet test`
Expected: all tests green.

---

## Task 6: Commit the probe feature

- [ ] **Step 1: Stage files**

```bash
git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs \
        src/AcDream.Core/Physics/BSPQuery.cs \
        src/AcDream.Core/Physics/TransitionTypes.cs \
        src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs \
        src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs \
        tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
```

- [ ] **Step 2: Commit**

```bash
git commit -m "$(cat <<'EOF'
feat(physics): Cluster A — indoor BSP collision probe

Adds the [indoor-bsp] probe + ProbeIndoorBspEnabled toggle for the
Indoor walking Phase 1 BSP-cluster investigation. Mirrors the existing
[resolve] / [cell-transit] / [indoor-*] pattern: one log line per
BSPQuery.FindCollisions call from FindEnvCollisions' cell branch,
capturing cell id, sphere local-pos, result TransitionState, and the
hit poly's normal + side-type via the LastBspHitPoly side-channel
(already wired for ProbeBuildingEnabled, now also fires for the indoor
flag).

Toggle via ACDREAM_PROBE_INDOOR_BSP=1 env var or DebugPanel checkbox.
Zero-cost when off.

Predecessor for the three fix commits that will close ISSUES.md
#84/#85/#86 after the capture session.

Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 3: Verify commit**

```bash
git log -1 --oneline
git status
```
Expected: One new commit on `claude/competent-robinson-dec1f4`; working tree clean.

---

## ━━━ CAPTURE GATE — runs once, between Task 6 and Task 7 ━━━

This gate requires the user to run the client. Do not attempt to fully automate it; the user is the test subject.

### Task 7: Capture session

**Goal:** Produce `launch.log` lines that pin the root cause of #84 and inform #85.

- [ ] **Step 1: Confirm `dotnet build` is green**

Run: `dotnet build`
Expected: 0 errors, 0 warnings.

- [ ] **Step 2: Hand the user the launch command**

Print the following block back to the user verbatim so they can paste it into PowerShell:

```powershell
$env:ACDREAM_DAT_DIR              = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE                 = "1"
$env:ACDREAM_TEST_HOST            = "127.0.0.1"
$env:ACDREAM_TEST_PORT            = "9000"
$env:ACDREAM_TEST_USER            = "testaccount"
$env:ACDREAM_TEST_PASS            = "testpassword"
$env:ACDREAM_PROBE_INDOOR_BSP     = "1"
$env:ACDREAM_PROBE_RESOLVE        = "1"
$env:ACDREAM_PROBE_CELL           = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
    Tee-Object -FilePath "launch-cluster-a-capture.log"
```

- [ ] **Step 3: Hand the user the capture script (three scenarios)**

Ask the user to perform, in order, while the client is in-world:

1. **Inside Inn walkaround.** Walk into Holtburg Inn through the front door. Once inside, walk slowly around the common room (front-to-back, then a circuit along the walls). Stop wherever an invisible block happens — note the on-screen position before retrying. ~30 seconds.
2. **Outside-in approach.** Exit the Inn. Stand 5–10 m from the Inn's west exterior wall in open ground. Sprint at the wall (`W` + Shift held). Observe whether you pass through. ~30 seconds.
3. **Inside-out sanity.** Re-enter the Inn through the door. Walk into one interior wall directly. Confirm the wall blocks (this is the working direction). ~15 seconds.

Total: ~80 seconds of walking. One launch.

- [ ] **Step 4: Wait for the user to confirm capture is done**

After the user closes the client, the log is at `launch-cluster-a-capture.log` in the worktree root.

---

## Task 8: Diagnose #84 from captured log

**Files:** Read-only.

- [ ] **Step 1: Confirm the log exists and has indoor-bsp lines**

Run: `rg -c "^\[indoor-bsp\]" launch-cluster-a-capture.log`
Expected: a positive number (the count of probe lines).

If 0: the probe didn't fire — either the user wasn't actually in an indoor cell, or the flag wasn't set. Re-check launch command and re-capture.

- [ ] **Step 2: Find the most-frequent "Collided" cell + poly**

Run:
```bash
rg "^\[indoor-bsp\]" launch-cluster-a-capture.log | rg "result=Collided" | head -200
```

Identify recurrent patterns. Look for:
- Same `cell=0x...` appearing repeatedly even when the player visually wasn't near a wall.
- `lpos=` Z component that's slightly negative (`-0.02`-ish) → +0.02f Z-bump hypothesis.
- Polys with `n=(0,0,1)` (floor up-normals) firing far from visible floor edges → bogus floor poly hypothesis.
- Polys with `sides=Back` or unusual side-types → one-sided handling hypothesis.

- [ ] **Step 3: Cross-ref with the user's reported invisible-block positions**

Use the world position from each `wpos=` field to identify which probe lines correspond to actual user-reported invisible blocks. The user reports them by approximate location; the log gives exact Z + cell. If user reports "near the back wall but a meter shy", filter `[indoor-bsp]` to that cell's lines and identify what poly fired.

- [ ] **Step 4: Identify ONE specific root cause**

Pin to one of:
- **(a) Z-bump asymmetry**: `lpos.Z` consistently slightly below 0 while `n=(0,0,1)` polys collide. Fix: remove the +0.02f from the physics path's `cellTransform` while keeping it for render, OR bump player Z by +0.02f when in an indoor cell.
- **(b) Bogus physics-only polys**: collisions fire at world positions where the user reports no visible wall, AND the contacted poly's normal points in a direction inconsistent with any visible geometry. Fix: filter polys by side-type at cache time OR ignore polys whose plane doesn't intersect the cell's visible volume.
- **(c) Step-up regression at cell boundary**: collisions fire as the player crosses a cell boundary (preceded by a `[cell-transit]` line). Fix: ensure the cell-BSP path handles the cell-id-change case correctly.
- **(d) Something the data shows that we didn't predict.** Write a one-paragraph note in the eventual commit message.

- [ ] **Step 5: Write a one-paragraph diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**

This doc is the evidence file for the upcoming commits. Format:

```markdown
# Cluster A — captured diagnosis (2026-05-19)

**Capture:** `launch-cluster-a-capture.log`.

## #84 root cause

<one paragraph: which probe lines, what they showed, what code site is implicated>

Sample probe line:
```
[indoor-bsp] cell=0x... wpos=... lpos=... result=Collided poly=... n=... sides=...
```

## #85 root cause

(filled by Task 9)
```

- [ ] **Step 6: No commit yet — proceed to fix.**

---

## Task 9: Apply #84 fix

**Files:** TBD based on Task 8's diagnosis. Most likely candidates:
- `src/AcDream.App/Rendering/GameWindow.cs` (the `+0.02f` Z-bump at line 5362).
- `src/AcDream.Core/Physics/TransitionTypes.cs` (the cell-BSP branch).
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the polygon resolve step).
- `src/AcDream.Core/Physics/BSPQuery.cs` (the BSP query dispatcher).

- [ ] **Step 1: Apply the surgical fix**

Per the diagnosis. Code samples for the most likely two cases:

**If (a) Z-bump asymmetry**: at GameWindow.cs:5360-5365, split the bumped transform into a render-only Z-bump while keeping physics aligned to terrain. Replace:

```csharp
                        var cellOrigin = envCell.Position.Origin + lbOffset
                            + new System.Numerics.Vector3(0f, 0f, 0.02f);
                        var cellTransform =
                            System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
                            System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);

                        var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
```

with:

```csharp
                        // Two cellOrigins: render is bumped +0.02 m on Z to
                        // prevent z-fight with terrain; physics stays aligned
                        // with terrain so the player's foot-Z (from terrain
                        // sample) matches the cell BSP's local floor.
                        // (Cluster A #84 — capture identified the bump as the
                        // source of "blocked by air" at cell boundaries.)
                        var cellOriginPhysics = envCell.Position.Origin + lbOffset;
                        var cellOriginRender  = cellOriginPhysics
                            + new System.Numerics.Vector3(0f, 0f, 0.02f);

                        var orientationMat =
                            System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation);

                        var cellTransformRender =
                            orientationMat *
                            System.Numerics.Matrix4x4.CreateTranslation(cellOriginRender);
                        var cellTransformPhysics =
                            orientationMat *
                            System.Numerics.Matrix4x4.CreateTranslation(cellOriginPhysics);

                        var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransformRender);
```

Then at line 5384, change the physics cache call to pass `cellTransformPhysics`:

```csharp
                        _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransformPhysics);
```

And at line 5381 (the `BuildLoadedCell` call), evaluate whether to pass render or physics (whichever the visibility code path uses). Inspect `BuildLoadedCell` and adjust if needed.

**If (b) bogus physics-only polys**: filter in `PhysicsDataCache.ResolvePolygons` at line 155-204 by skipping polys whose `SidesType` value indicates a back-face-only or physics-stub. Reference: check `DatReaderWriter.Types.SidesType` enum. Add a `continue` for any side-type identified in capture.

- [ ] **Step 2: Build**

Run: `dotnet build`
Expected: Build succeeds.

- [ ] **Step 3: Run physics tests**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Physics"`
Expected: all green.

- [ ] **Step 4: Re-launch the client + Inside-Inn walkaround**

Same launch command as Task 7 Step 2. User walks the same inside-Inn loop. Verify no invisible blocks. `rg "^\[indoor-bsp\]" launch.log | rg result=Collided | wc -l` should be ~0 except at actual walls.

- [ ] **Step 5: Commit the fix**

```bash
git add <files-touched>
git commit -m "$(cat <<'EOF'
fix(physics): Cluster A #84 — <one-line root cause>

<3-5 line description citing the probe-line evidence>

Closes #84.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Replace `<one-line root cause>` and the body with the actual evidence from Task 8 / Step 5's diagnosis doc.

---

## Task 10: Diagnose #85 from captured log

**Files:** Read-only.

- [ ] **Step 1: Confirm scenario 2 lines are present**

Run:
```bash
rg "^\[resolve\]" launch-cluster-a-capture.log | head -100
```

Look for lines emitted during the outside-in approach. Time-correlate via line ordering — scenario 2 follows scenario 1.

- [ ] **Step 2: Identify what (if anything) was hit during outside-in approach**

If `[resolve]` lines during scenario 2 show `obj=0x...` for the building stab, the outdoor BSP IS being consulted — the question is why it doesn't block. Inspect that stab's polys via:
```bash
rg "0xA9B47900" launch-cluster-a-capture.log     # adjust to the obj id observed
```

If no `[resolve]` lines have `obj=...` for the building during scenario 2, the outdoor BSP isn't being engaged at all — the question is why `FindObjCollisions` doesn't iterate the building's stab.

- [ ] **Step 3: Identify ONE specific root cause**

Pin to one of:
- **(α) Building stab BSP exists but polys are one-sided**. Approach from outside fails the BSP traversal direction test. Fix: change side-type handling in `BSPQuery.FindCollisions` for the outdoor-stab path OR mark stab polys two-sided at cache time.
- **(β) Building stab is in the loaded set but never iterated**. The `FindObjCollisions` loop skips it for some reason (cell mismatch, scale mismatch, etc.). Fix: ensure the building stab's shadow-entry registration covers the outdoor cells the player walks through.
- **(γ) Building stab has no wall polys**. Retail's building shells are partial — they cover floor/roof, with interior walls in the EnvCell. Fix: port retail's cross-cell BSP probing (when sphere overlaps an EnvCell's world AABB from an outdoor cell, query that EnvCell's BSP too).
- **(δ) Risk path: if (γ) is the root cause and the port is large**, promote #85 to its own phase. Pause this plan and write a new phase spec for the cross-cell BSP work, then return to this plan for #86.

- [ ] **Step 4: Add #85 diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**

Mirror the format from Task 8 Step 5.

- [ ] **Step 5: If route (δ) triggers — split out #85**

Stop the plan here. Write a new spec `docs/superpowers/specs/2026-05-DD-cluster-a-cross-cell-bsp-design.md` for #85's cross-cell port; come back to this plan and skip to Task 12 (#86 fix) immediately.

---

## Task 11: Apply #85 fix

**Files:** TBD based on Task 10's diagnosis. Most likely candidates:
- `src/AcDream.Core/Physics/BSPQuery.cs` (side-type handling).
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (stab caching).
- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` or cell-BSP cross-probe).
- `src/AcDream.App/Rendering/GameWindow.cs` (cell-stab registration).

- [ ] **Step 1: Apply the surgical fix**

Per the Task 10 diagnosis. The actual code is determined by which root cause (α / β / γ) the capture pinned. The diagnosis doc records the evidence; the commit body cites it.

- [ ] **Step 2: Build + test**

Run: `dotnet build && dotnet test`
Expected: all green.

- [ ] **Step 3: Re-launch + outside-in scenario**

User stands 5+ m west of the Inn, sprints at the wall. Verify: player blocks at the wall plane.

- [ ] **Step 4: Commit**

```bash
git add <files-touched>
git commit -m "fix(physics): Cluster A #85 — <root cause>

<evidence-citing description>

Closes #85.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Task 12: Create `CellBspRayOccluder` for #86

**Files:**
- Create: `src/AcDream.Core/Selection/CellBspRayOccluder.cs`
- Create: `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`

- [ ] **Step 1: Write the test file FIRST (TDD)**

Create `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`:

```csharp
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using DatReaderWriter.Types;
using Xunit;

namespace AcDream.Core.Tests.Selection;

public class CellBspRayOccluderTests
{
    // Build a CellPhysics with a single triangular poly at world-Y=10.
    // Triangle vertices in local space, then world transform = identity.
    private static CellPhysics MakeWallCell()
    {
        var verts = new[]
        {
            new Vector3(-5, 10, 0),
            new Vector3( 5, 10, 0),
            new Vector3( 0, 10, 5),
        };
        var poly = new ResolvedPolygon
        {
            Vertices  = verts,
            Plane     = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
            NumPoints = 3,
            SidesType = SidesType.Front,
        };
        return new CellPhysics
        {
            BSP = null,                       // Occluder doesn't use BSP — direct poly iteration.
            Resolved = new() { [0] = poly },
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
        };
    }

    [Fact]
    public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
    {
        var cell = MakeWallCell();
        var origin = new Vector3(0, 0, 1);
        var direction = Vector3.UnitY;   // travels +Y toward the wall at Y=10
        float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
        Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
    }

    [Fact]
    public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
    {
        var cell = MakeWallCell();
        var origin = new Vector3(0, 0, 1);
        var direction = -Vector3.UnitY;  // travels AWAY from the wall
        float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
        Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
    }

    [Fact]
    public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
    {
        var origin = Vector3.Zero;
        var direction = Vector3.UnitY;
        float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty<CellPhysics>());
        Assert.True(float.IsPositiveInfinity(t));
    }

    [Fact]
    public void NearestWallT_TwoCells_ReturnsNearer()
    {
        var nearCell = MakeWallCell();                  // wall at Y=10
        var farCell  = MakeWallCell();
        // Move farCell's transform to push it to Y=20.
        farCell.WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0);
        Matrix4x4.Invert(farCell.WorldTransform, out var inv);
        farCell.InverseWorldTransform = inv;

        var origin = new Vector3(0, 0, 1);
        var direction = Vector3.UnitY;
        float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
        Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
    }
}
```

- [ ] **Step 2: Run the test — expect failure**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
Expected: FAIL (`CellBspRayOccluder` not found).

- [ ] **Step 3: Implement the occluder**

Create `src/AcDream.Core/Selection/CellBspRayOccluder.cs`:

```csharp
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;

namespace AcDream.Core.Selection;

/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
/// (currently-loaded EnvCells with resolved polygon planes), returns
/// the nearest world-space <c>t</c> along the ray that hits any cell
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
/// all cells.
///
/// <para>
/// Used by <see cref="WorldPicker.Pick"/> to filter entities that sit
/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
/// ray-triangle intersection; one test per triangle. Cells are
/// transformed via their <see cref="CellPhysics.InverseWorldTransform"/>
/// so the ray runs in cell-local space and the resolved-polygon
/// vertices don't need re-transformation per query.
/// </para>
///
/// <para>
/// No BSP traversal — iterates every polygon in every cell. Cell count
/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
/// modern hardware; one <c>Pick</c> call is well under 1 ms.
/// </para>
/// </summary>
public static class CellBspRayOccluder
{
    /// <summary>
    /// Returns the nearest positive <c>t</c> such that
    /// <c>origin + t * direction</c> intersects a polygon in any cell.
    /// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
    /// is intersected.
    /// </summary>
    /// <param name="direction">Need not be normalized; returned <c>t</c>
    /// scales with direction length the same as a parametric ray.</param>
    public static float NearestWallT(
        Vector3 origin,
        Vector3 direction,
        IEnumerable<CellPhysics> loadedCells)
    {
        if (loadedCells is null) return float.PositiveInfinity;

        float bestT = float.PositiveInfinity;
        foreach (var cell in loadedCells)
        {
            if (cell?.Resolved is null) continue;

            // Bring the ray into cell-local space ONCE per cell.
            var localOrigin    = Vector3.Transform(origin, cell.InverseWorldTransform);
            var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);

            foreach (var (_, poly) in cell.Resolved)
            {
                // Triangulate the (possibly polygonal) face into a fan.
                int n = poly.NumPoints;
                if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
                    continue;

                for (int i = 1; i < n - 1; i++)
                {
                    if (TryRayTriangle(
                            localOrigin, localDirection,
                            poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
                            out var t)
                        && t < bestT)
                    {
                        bestT = t;
                    }
                }
            }
        }
        return bestT;
    }

    /// <summary>
    /// Möller-Trumbore ray-triangle intersection. Returns true with
    /// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
    /// at a positive distance.
    /// </summary>
    private static bool TryRayTriangle(
        Vector3 origin, Vector3 direction,
        Vector3 v0, Vector3 v1, Vector3 v2,
        out float t)
    {
        const float Epsilon = 1e-7f;

        var edge1 = v1 - v0;
        var edge2 = v2 - v0;
        var pvec  = Vector3.Cross(direction, edge2);
        float det = Vector3.Dot(edge1, pvec);

        // No two-sided handling here — picker should be permissive so
        // a wall blocks regardless of which side the camera is on.
        if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
        float invDet = 1f / det;

        var tvec = origin - v0;
        float u = Vector3.Dot(tvec, pvec) * invDet;
        if (u < 0f || u > 1f) { t = 0f; return false; }

        var qvec = Vector3.Cross(tvec, edge1);
        float v = Vector3.Dot(direction, qvec) * invDet;
        if (v < 0f || u + v > 1f) { t = 0f; return false; }

        t = Vector3.Dot(edge2, qvec) * invDet;
        return t > Epsilon;
    }
}
```

- [ ] **Step 4: Run the tests — expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
Expected: 4 tests passing.

---

## Task 13: Wire `WorldPicker.Pick` to use the occluder

**Files:**
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`

- [ ] **Step 1: Write the failing integration test**

Create `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`:

```csharp
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter.Types;
using Xunit;

namespace AcDream.Core.Tests.Selection;

public class WorldPickerCellOcclusionTests
{
    private static CellPhysics MakeWallAtY10()
    {
        var verts = new[]
        {
            new Vector3(-5, 10, -5),
            new Vector3( 5, 10, -5),
            new Vector3( 5, 10,  5),
            new Vector3(-5, 10,  5),
        };
        var poly = new ResolvedPolygon
        {
            Vertices  = verts,
            Plane     = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
            NumPoints = 4,
            SidesType = SidesType.Front,
        };
        return new CellPhysics
        {
            BSP = null,
            Resolved = new() { [0] = poly },
            WorldTransform = Matrix4x4.Identity,
            InverseWorldTransform = Matrix4x4.Identity,
        };
    }

    private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
    {
        Id = guid,
        ServerGuid = guid,
        SourceGfxObjOrSetupId = 0,
        Position = pos,
        Rotation = Quaternion.Identity,
        MeshRefs = System.Array.Empty<MeshRef>(),
    };

    [Fact]
    public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
    {
        var wall = MakeWallAtY10();
        var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));  // entity at Y=20, wall at Y=10

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: Vector3.UnitY,
            candidates: new[] { entity },
            skipServerGuid: 0u,
            cellOccluder: (origin, direction) =>
                CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));

        Assert.Null(result);
    }

    [Fact]
    public void Pick_RaySphere_NoWall_HitsEntity()
    {
        var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));

        var result = WorldPicker.Pick(
            origin: Vector3.Zero,
            direction: Vector3.UnitY,
            candidates: new[] { entity },
            skipServerGuid: 0u,
            cellOccluder: null);  // null occluder = no occlusion

        Assert.Equal(0xABCDu, result);
    }
}
```

- [ ] **Step 2: Run the test — expect compile failure (cellOccluder param doesn't exist yet)**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion"`
Expected: build FAIL (`cellOccluder` param not on Pick).

- [ ] **Step 3: Add `cellOccluder` parameter to the legacy ray-sphere `Pick`**

In `src/AcDream.Core/Selection/WorldPicker.cs`, change the legacy `Pick` signature (line 88-95) from:

```csharp
    public static uint? Pick(
        Vector3 origin, Vector3 direction,
        IEnumerable<WorldEntity> candidates,
        uint skipServerGuid,
        float maxDistance = 50f,
        Func<uint, float>? radiusForGuid = null,
        Func<uint, float>? verticalOffsetForGuid = null)
```

to:

```csharp
    public static uint? Pick(
        Vector3 origin, Vector3 direction,
        IEnumerable<WorldEntity> candidates,
        uint skipServerGuid,
        float maxDistance = 50f,
        Func<uint, float>? radiusForGuid = null,
        Func<uint, float>? verticalOffsetForGuid = null,
        Func<Vector3, Vector3, float>? cellOccluder = null)
```

Then, inside the method, BEFORE the foreach loop over candidates, capture the wall-t once:

```csharp
        float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
```

And inside the candidate loop, immediately before the `if (t < bestT)` line, add:

```csharp
            if (t >= wallT) continue;   // wall is between camera and entity
```

- [ ] **Step 4: Run the legacy-overload test — expect green**

Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion.Pick_RaySphere"`
Expected: 2 tests passing.

- [ ] **Step 5: Add cellOccluder to the screen-rect Pick overload**

In `src/AcDream.Core/Selection/WorldPicker.cs`, change the screen-rect `Pick` signature (line 202-211) from:

```csharp
    public static uint? Pick(
        float mouseX, float mouseY,
        Matrix4x4 view,
        Matrix4x4 projection,
        Vector2 viewport,
        IEnumerable<WorldEntity> candidates,
        uint skipServerGuid,
        Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
        float inflatePixels = 8f)
```

to:

```csharp
    public static uint? Pick(
        float mouseX, float mouseY,
        Matrix4x4 view,
        Matrix4x4 projection,
        Vector2 viewport,
        IEnumerable<WorldEntity> candidates,
        uint skipServerGuid,
        Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
        float inflatePixels = 8f,
        Func<Vector3, Vector3, float>? cellOccluder = null)
```

Inside the method, BEFORE the foreach over candidates, build the click ray and query the occluder. After computing the ray via the existing `BuildRay` helper (or `Matrix4x4.Invert(vp)` if `BuildRay` isn't directly callable due to viewport plumbing), use:

```csharp
        var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
        float wallT = cellOccluder?.Invoke(rayOrigin, rayDir) ?? float.PositiveInfinity;
        // Convert wall t (world-space distance along normalized ray dir)
        // to camera-space depth for comparison with `depth` from
        // ScreenProjection.TryProjectSphereToScreenRect.
        Vector3 wallPoint = float.IsPositiveInfinity(wallT)
            ? new Vector3(0, 0, 0)
            : rayOrigin + rayDir * wallT;
        float wallDepth = float.IsPositiveInfinity(wallT)
            ? float.PositiveInfinity
            : Vector3.Transform(wallPoint, view).Z * -1f;   // camera looks -Z; depth is positive
```

Inside the candidate loop, just before `if (depth < bestDepth)`:

```csharp
            if (depth > wallDepth) continue;
```

**Note:** The camera-space depth math assumes the engine uses the System.Numerics row-vector convention (`view * projection`). Verify by reading the existing `ScreenProjection.TryProjectSphereToScreenRect` to see how `depth` is computed, and match.

- [ ] **Step 6: Wire the occluder from GameWindow**

In `src/AcDream.App/Rendering/GameWindow.cs` at the picker call (line 9134), add a `cellOccluder` argument that snapshots the currently-loaded cells:

```csharp
        // Cluster A #86 (2026-05-19): occlude entities behind walls.
        // Snapshot the currently-loaded EnvCells' physics — picker uses
        // ray-vs-poly to gate selection through walls.
        var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
        foreach (var cellId in EnumerateLoadedEnvCellIds())   // see helper below
        {
            var cp = _physicsDataCache.GetCellStruct(cellId);
            if (cp is not null) loadedCellPhysics.Add(cp);
        }

        var picked = AcDream.Core.Selection.WorldPicker.Pick(
            mouseX: _lastMouseX, mouseY: _lastMouseY,
            view: camera.View, projection: camera.Projection,
            viewport: viewport,
            candidates: _entitiesByServerGuid.Values,
            skipServerGuid: _playerServerGuid,
            sphereForEntity: e => /* unchanged */ ...,
            inflatePixels: 8f,
            cellOccluder: (origin, direction) =>
                AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics));
```

Add a small helper above `OnPick` (or wherever fits):

```csharp
    /// <summary>
    /// Cluster A #86 helper. Returns the EnvCell ids whose physics BSP
    /// is currently cached and may occlude a picker ray. Authoritative
    /// source TBD during integration — check whether `_cellVisibility`
    /// exposes a public set, otherwise iterate `_pendingCellMeshes.Keys`
    /// or the equivalent.
    /// </summary>
    private IEnumerable<uint> EnumerateLoadedEnvCellIds()
    {
        // Confirm authoritative source during integration. _physicsDataCache
        // already has `_cellStruct` (private). Easiest path: add a public
        // `IReadOnlyCollection<uint> CellStructIds` getter to PhysicsDataCache.
        return _physicsDataCache.CellStructIds;
    }
```

If `PhysicsDataCache` doesn't yet expose `CellStructIds`, add it. Edit `src/AcDream.Core/Physics/PhysicsDataCache.cs` near the existing `CellStructCount` property:

```csharp
    /// <summary>
    /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
    /// EnvCell ids — used by <see cref="WorldPicker"/> to enumerate
    /// occluder candidates without exposing the underlying dictionary.
    /// </summary>
    public IReadOnlyCollection<uint> CellStructIds => _cellStruct.Keys;
```

- [ ] **Step 7: Build + test**

Run: `dotnet build && dotnet test`
Expected: all green, including the two new `WorldPickerCellOcclusionTests`.

- [ ] **Step 8: Visual verify**

Re-launch the client. Mouse over the Inn's west exterior wall from open ground: cursor should NOT show a selection ring for any indoor entities. Mouse through the Inn's open door at an inside NPC: selection works.

- [ ] **Step 9: Commit**

```bash
git add src/AcDream.Core/Selection/CellBspRayOccluder.cs \
        src/AcDream.Core/Selection/WorldPicker.cs \
        src/AcDream.Core/Physics/PhysicsDataCache.cs \
        src/AcDream.App/Rendering/GameWindow.cs \
        tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs \
        tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs

git commit -m "$(cat <<'EOF'
fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker

WorldPicker.Pick previously had no occlusion test — any entity along
the click ray within maxDistance was a candidate, including ones
behind walls. Adds the CellBspRayOccluder static helper that
Möller-Trumbore-tests the click ray against every polygon in every
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
Both Pick overloads gate candidate selection by that wall-t (legacy
ray-sphere via world-space `t`, screen-rect via camera-space depth).

PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
caller can iterate without needing the private cache dictionary.

Closes #86.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 14: Close out docs

**Files:**
- Modify: `docs/ISSUES.md`
- Modify: `docs/plans/2026-04-11-roadmap.md`
- Modify: `CLAUDE.md` (only the "Currently working toward / current phase" line; otherwise leave alone)
- Create: `docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md`

- [ ] **Step 1: Move issues #84, #85, #86 to "Recently closed" in `docs/ISSUES.md`**

For each issue, change `**Status:** OPEN` to `**Status:** DONE`, add a `**Closed:** YYYY-MM-DD · <commit-sha>` line, and move the block to the "Recently closed" section at the bottom of the file (mirroring the format of other DONE entries).

- [ ] **Step 2: Add shipped entry to `docs/plans/2026-04-11-roadmap.md`**

Add a row to the "Recently shipped" table at the top of the roadmap doc. Format matches the existing "Indoor cell rendering Phase 2" row (which landed earlier today).

- [ ] **Step 3: Update CLAUDE.md "Currently in Phase..." paragraph**

Open `CLAUDE.md`. The block at the start of the "Roadmap discipline" section names the current phase. Update to reflect that Indoor walking Phase 1 shipped, and that the next item is Indoor walking Phase 2 (the visibility cluster — #78) OR a return to the M2 critical path (F.2/F.3/etc.) — pick per CLAUDE.md's work-order autonomy rule and announce in commit message.

- [ ] **Step 4: Write the shipped handoff doc**

Create `docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md` (replace placeholder with actual date). Format mirrors `docs/research/2026-05-14-b5-shipped-handoff.md`:

```markdown
# Indoor walking Phase 1 — shipped handoff

**Date:** YYYY-MM-DD
**Commits:** <sha-list>
**Closes:** ISSUES.md #84, #85, #86

## Probe evidence

(paste 3-5 representative `[indoor-bsp]` lines from `launch-cluster-a-capture.log`)

## Root causes

- **#84:** (one paragraph)
- **#85:** (one paragraph)
- **#86:** (one paragraph — WorldPicker had no cell-BSP test, pinned by code-reading not by capture)

## Files touched

(short list grouped by commit)

## Follow-up

(any new issues filed during this phase, e.g. an extension of the probe scope, or items deferred to Indoor walking Phase 2)
```

- [ ] **Step 5: Final build + test sweep**

Run: `dotnet build && dotnet test`
Expected: 0 errors, all green.

- [ ] **Step 6: Commit the docs**

```bash
git add docs/ISSUES.md \
        docs/plans/2026-04-11-roadmap.md \
        CLAUDE.md \
        docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md

git commit -m "$(cat <<'EOF'
docs(phase): Indoor walking Phase 1 — shipped

Closes ISSUES.md #84, #85, #86. Adds shipped handoff doc with probe
evidence + root cause summaries. Roadmap and CLAUDE.md current-phase
pointer updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Self-review checklist (run after writing the plan; the engineer doesn't run this)

1. **Spec coverage**: every spec section maps to a task. ✓ §1-3 → Tasks 1-3 + facts; §4 components → Tasks 1-6 (probe), Task 12-13 (picker); §5 data flow → Tasks 7-11; §6 commit shape → Task 6 + 9 + 11 + 13 + 14; §7 files → File Structure table; §9 testing → Tasks 5, 12, 13; §10 acceptance → Task 14 Step 4 handoff doc.
2. **Placeholder scan**: post-capture tasks (8-11) intentionally carry parameterized fixes since the exact fix is unknown pre-capture; the runbook structure gives concrete commands + a decision tree. This is honest about the phase shape, not a placeholder.
3. **Type consistency**: `ProbeIndoorBspEnabled` (PhysicsDiagnostics) ↔ `ProbeIndoorBsp` (DebugVM) ↔ `ACDREAM_PROBE_INDOOR_BSP` (env var) ↔ "Indoor: BSP collision" (DebugPanel label) — verified consistent throughout. `CellBspRayOccluder.NearestWallT` signature consistent across Tasks 12 and 13.
4. **Acceptance**: matches spec §10 + design §13 risk #2 (the split-out option for #85 if the fix scope explodes).
