acdream/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
Erik 18a2e28875 docs(plan): Indoor walking Phase 1 — BSP cluster implementation plan
14-task plan covering the diagnostic-driven phase: probe + capture +
three fix commits + docs. Tasks 1-6 land the [indoor-bsp] probe in
one feature commit. Task 7 is the user-run capture gate. Tasks 8-11
do post-capture diagnosis + fix for #84 and #85 (with a route-δ
escape hatch if #85's fix turns out to be a large cross-cell port).
Tasks 12-13 ship the WorldPicker cell-BSP occlusion fix for #86
(no capture dependency — pinned by code-reading). Task 14 closes
out ISSUES.md + roadmap + ships the post-phase handoff doc.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:15:50 +02:00

1297 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 510 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).