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

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

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

54 KiB
Raw Blame History

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


File Structure

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

Probe pre-work: code-shape facts

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

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

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

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

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

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

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

Fact 7. Three loaded sets the picker needs:

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

Task 1: Add PhysicsDiagnostics.ProbeIndoorBspEnabled

Files:

  • Modify: src/AcDream.Core/Physics/PhysicsDiagnostics.cs

  • Step 1: Add the new toggle property at the bottom of the existing toggle list

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

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

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

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

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

Files:

  • Modify: src/AcDream.Core/Physics/BSPQuery.cs

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

  • Step 1: Find every write site

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

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

  • Step 2: Replace the gate at each site

For each of the 8 occurrences, edit the gate from:

if (PhysicsDiagnostics.ProbeBuildingEnabled)
    PhysicsDiagnostics.LastBspHitPoly = hitPoly;

to:

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:

                // 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:

                // 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:

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

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

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

        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;:

        // 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:

[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
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
git commit -m "$(cat <<'EOF'
feat(physics): Cluster A — indoor BSP collision probe

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 3: Verify commit
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:

$env:ACDREAM_DAT_DIR              = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE                 = "1"
$env:ACDREAM_TEST_HOST            = "127.0.0.1"
$env:ACDREAM_TEST_PORT            = "9000"
$env:ACDREAM_TEST_USER            = "testaccount"
$env:ACDREAM_TEST_PASS            = "testpassword"
$env:ACDREAM_PROBE_INDOOR_BSP     = "1"
$env:ACDREAM_PROBE_RESOLVE        = "1"
$env:ACDREAM_PROBE_CELL           = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
    Tee-Object -FilePath "launch-cluster-a-capture.log"
  • Step 3: Hand the user the capture script (three scenarios)

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

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

Total: ~80 seconds of walking. One launch.

  • Step 4: Wait for the user to confirm capture is done

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


Task 8: Diagnose #84 from captured log

Files: Read-only.

  • Step 1: Confirm the log exists and has indoor-bsp lines

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

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

  • Step 2: Find the most-frequent "Collided" cell + poly

Run:

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:

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

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

## #84 root cause

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

Sample probe line:

[indoor-bsp] cell=0x... wpos=... lpos=... result=Collided poly=... n=... sides=...


## #85 root cause

(filled by Task 9)
  • Step 6: No commit yet — proceed to fix.

Task 9: Apply #84 fix

Files: TBD based on Task 8's diagnosis. Most likely candidates:

  • src/AcDream.App/Rendering/GameWindow.cs (the +0.02f Z-bump at line 5362).

  • src/AcDream.Core/Physics/TransitionTypes.cs (the cell-BSP branch).

  • src/AcDream.Core/Physics/PhysicsDataCache.cs (the polygon resolve step).

  • src/AcDream.Core/Physics/BSPQuery.cs (the BSP query dispatcher).

  • Step 1: Apply the surgical fix

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

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

                        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:

                        // 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:

                        _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
git add <files-touched>
git commit -m "$(cat <<'EOF'
fix(physics): Cluster A #84 — <one-line root cause>

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

Closes #84.

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

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


Task 10: Diagnose #85 from captured log

Files: Read-only.

  • Step 1: Confirm scenario 2 lines are present

Run:

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:

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
git add <files-touched>
git commit -m "fix(physics): Cluster A #85 — <root cause>

<evidence-citing description>

Closes #85.

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

Task 12: Create CellBspRayOccluder for #86

Files:

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

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

  • Step 1: Write the test file FIRST (TDD)

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

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

namespace AcDream.Core.Tests.Selection;

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

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

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

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

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

        var origin = new Vector3(0, 0, 1);
        var direction = Vector3.UnitY;
        float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
        Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
    }
}
  • Step 2: Run the test — expect failure

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

  • Step 3: Implement the occluder

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

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

namespace AcDream.Core.Selection;

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

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

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

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

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

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

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

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

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

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

        t = Vector3.Dot(edge2, qvec) * invDet;
        return t > Epsilon;
    }
}
  • Step 4: Run the tests — expect green

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


Task 13: Wire WorldPicker.Pick to use the occluder

Files:

  • Modify: src/AcDream.Core/Selection/WorldPicker.cs

  • Modify: src/AcDream.App/Rendering/GameWindow.cs

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

  • Step 1: Write the failing integration test

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

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

namespace AcDream.Core.Tests.Selection;

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

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

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

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

        Assert.Null(result);
    }

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

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

        Assert.Equal(0xABCDu, result);
    }
}
  • Step 2: Run the test — expect compile failure (cellOccluder param doesn't exist yet)

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

  • Step 3: Add cellOccluder parameter to the legacy ray-sphere Pick

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

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

to:

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

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

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

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

            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:

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

to:

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

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

        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):

            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:

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

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

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

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

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

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

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

  • Step 8: Visual verify

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

  • Step 9: Commit
git add src/AcDream.Core/Selection/CellBspRayOccluder.cs \
        src/AcDream.Core/Selection/WorldPicker.cs \
        src/AcDream.Core/Physics/PhysicsDataCache.cs \
        src/AcDream.App/Rendering/GameWindow.cs \
        tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs \
        tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs

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

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

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

Closes #86.

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

Task 14: Close out docs

Files:

  • Modify: docs/ISSUES.md

  • Modify: docs/plans/2026-04-11-roadmap.md

  • Modify: CLAUDE.md (only the "Currently working toward / current phase" line; otherwise leave alone)

  • Create: docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md

  • Step 1: Move issues #84, #85, #86 to "Recently closed" in docs/ISSUES.md

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

  • Step 2: Add shipped entry to docs/plans/2026-04-11-roadmap.md

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

  • Step 3: Update CLAUDE.md "Currently in Phase..." paragraph

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

  • Step 4: Write the shipped handoff doc

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

# Indoor walking Phase 1 — shipped handoff

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

## Probe evidence

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

## Root causes

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

## Files touched

(short list grouped by commit)

## Follow-up

(any new issues filed during this phase, e.g. an extension of the probe scope, or items deferred to Indoor walking Phase 2)
  • Step 5: Final build + test sweep

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

  • Step 6: Commit the docs
git add docs/ISSUES.md \
        docs/plans/2026-04-11-roadmap.md \
        CLAUDE.md \
        docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md

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

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

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

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

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