diff --git a/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md new file mode 100644 index 0000000..7284d91 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md @@ -0,0 +1,1297 @@ +# 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` — 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 + /// + /// Indoor walking Phase 1 (2026-05-19). When true, emits one + /// [indoor-bsp] line per + /// call made from 's indoor + /// cell-BSP branch. Captures the cell id, sphere local position, + /// resulting , 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). + /// + /// + /// While true, this also un-gates the diagnostic + /// side-channel inside + /// — see the OR'd condition at every poly + /// write site. Zero-cost when off. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1. + /// Runtime-toggleable via DebugPanel. + /// + /// + /// + /// Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md. + /// + /// + 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 + /// + /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of + /// PhysicsDiagnostics.ProbeIndoorBspEnabled (env var + /// ACDREAM_PROBE_INDOOR_BSP). Toggling here flips the + /// [indoor-bsp] probe live — no relaunch required. + /// Physics-side companion to the five render-side + /// ProbeIndoor* mirrors directly above. + /// + 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) +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 + + + +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 +git commit -m "$(cat <<'EOF' +fix(physics): Cluster A #84 — + +<3-5 line description citing the probe-line evidence> + +Closes #84. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Replace `` 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 +git commit -m "fix(physics): Cluster A #85 — + + + +Closes #85. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## 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()); + 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; + +/// +/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon +/// occlusion test. Given a ray and a set of +/// (currently-loaded EnvCells with resolved polygon planes), returns +/// the nearest world-space t along the ray that hits any cell +/// polygon — or if the ray clears +/// all cells. +/// +/// +/// Used by 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 +/// so the ray runs in cell-local space and the resolved-polygon +/// vertices don't need re-transformation per query. +/// +/// +/// +/// 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 Pick call is well under 1 ms. +/// +/// +public static class CellBspRayOccluder +{ + /// + /// Returns the nearest positive t such that + /// origin + t * direction intersects a polygon in any cell. + /// Returns if no cell polygon + /// is intersected. + /// + /// Need not be normalized; returned t + /// scales with direction length the same as a parametric ray. + public static float NearestWallT( + Vector3 origin, + Vector3 direction, + IEnumerable 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; + } + + /// + /// Möller-Trumbore ray-triangle intersection. Returns true with + /// t in if the ray hits the triangle + /// at a positive distance. + /// + 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(), + }; + + [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 candidates, + uint skipServerGuid, + float maxDistance = 50f, + Func? radiusForGuid = null, + Func? verticalOffsetForGuid = null) +``` + +to: + +```csharp + public static uint? Pick( + Vector3 origin, Vector3 direction, + IEnumerable candidates, + uint skipServerGuid, + float maxDistance = 50f, + Func? radiusForGuid = null, + Func? verticalOffsetForGuid = null, + Func? 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 candidates, + uint skipServerGuid, + Func sphereForEntity, + float inflatePixels = 8f) +``` + +to: + +```csharp + public static uint? Pick( + float mouseX, float mouseY, + Matrix4x4 view, + Matrix4x4 projection, + Vector2 viewport, + IEnumerable candidates, + uint skipServerGuid, + Func sphereForEntity, + float inflatePixels = 8f, + Func? 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(); + 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 + /// + /// 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. + /// + private IEnumerable EnumerateLoadedEnvCellIds() + { + // Confirm authoritative source during integration. _physicsDataCache + // already has `_cellStruct` (private). Easiest path: add a public + // `IReadOnlyCollection 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 + /// + /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached + /// EnvCell ids — used by to enumerate + /// occluder candidates without exposing the underlying dictionary. + /// + public IReadOnlyCollection 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) +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/-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 · ` 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/-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:** +**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/-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) +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).