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