diff --git a/docs/research/2026-05-21-walk-miss-capture-findings.md b/docs/research/2026-05-21-walk-miss-capture-findings.md
new file mode 100644
index 0000000..e3ac5a0
--- /dev/null
+++ b/docs/research/2026-05-21-walk-miss-capture-findings.md
@@ -0,0 +1,252 @@
+# Indoor walk-miss probe — capture findings (ISSUES #83)
+
+**Date:** 2026-05-21
+**Session:** lucid-goldberg-1ba520
+**Spec:** [`docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md)
+**Plan:** [`docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md`](../superpowers/plans/2026-05-21-indoor-walk-miss-probe.md)
+**Capture log:** `launch-walk-miss.utf8.log` (9,401 lines, this branch — uncommitted)
+
+## TL;DR
+
+**H3 is the dominant defect.** The indoor walkable-plane synthesis
+(`BSPQuery.FindWalkableSphere` → `FindWalkableInternal` →
+`walkable_hits_sphere` + `adjust_sphere_to_plane`) **rejects floor
+polygons it should accept** ~98 % of the time the player is standing on
+a horizontal indoor floor. The HIT zone is razor-thin: misses cluster
+at `dz=0.48 m` (cell-local foot-above-floor) while the only 7 HITs in
+the entire capture all sat at `dz=0.46 m` — a **2 cm boundary** between
+working and broken.
+
+H1 (multi-cell iteration missing) is real but secondary: 59 events
+(3 %) at doorway-threshold cells where the player stepped past a small
+indoor floor poly and the LandCell terrain would have grounded them.
+
+H2 (probe distance 0.5 m too short) is **not** the issue. The bulk of
+H3 misses sit well within the probe envelope.
+
+## Numbers
+
+| Metric | Count |
+|---|---:|
+| Total `[walk-miss]` events | 1,814 |
+| `[indoor-walkable] result=HIT` (synthesis succeeded) | 7 |
+| `[indoor-walkable] result=MISS` (synthesis failed) | 1,814 |
+| Synthesis HIT rate | **0.38 %** |
+| `[floor-polys]` cell dumps (one per cached indoor cell) | 527 |
+
+### Hypothesis classification (per spec disambiguation matrix)
+
+| Class | Filter | Count | % of total |
+|---|---|---:|---:|
+| **H3 candidate** | `containsFootXY=True AND \|dz\| ≤ 0.5 m` | **817** | **45.0 %** |
+| Airborne / jump | `containsFootXY=True AND \|dz\| > 0.5 m` | 938 | 51.7 % |
+| **H1 candidate** | `containsFootXY=False AND landcell.hasTerrain=true` | **59** | 3.3 % |
+| H1+H3 combo | `containsFootXY=False AND landcell.hasTerrain=false` | 0 | 0.0 % |
+
+The 938 "airborne" events are not a defect — they correspond to the
+test session's jump arc (the user jumped through the doorway during
+capture). The probe correctly reports `containsFootXY=True` with a
+large `dz` because the foot is XY-over a floor poly but vertically too
+far above it. Setting these aside: of **876 ground-contact misses**,
+**93 %** are H3.
+
+### `nearest.dz` distribution (containsFootXY=True only)
+
+| dz bucket | Count |
+|---|---:|
+| 0.0–0.2 m | 18 |
+| 0.2–0.4 m | 7 |
+| **0.4–0.5 m** | **792** |
+| 0.5–1.0 m | 141 |
+| 1.0–2.0 m | 427 |
+| > 2.0 m | 370 |
+| negative | 0 |
+
+The massive 792-event spike at 0.4–0.5 m is the standing-on-the-floor
+position. The 1.0–2.0 m and >2.0 m buckets are the jump arc.
+
+## The 2 cm hit/miss boundary
+
+The only 7 synthesis HITs in the capture share a precise property:
+
+| HIT example | foot.W.Z | world floor Z | dz |
+|---|---:|---:|---:|
+| `cell=0xA9B40125 wpos=(104.263, 140.893, 66.480)` | 66.480 | 66.020 | **+0.46** |
+| `cell=0xA9B40125 wpos=(104.272, 141.275, 66.480)` | 66.480 | 66.020 | +0.46 |
+| `cell=0xA9B40123 wpos=(108.430, 134.116, 69.485)` | 69.485 | 69.020 | +0.47 |
+| `cell=0xA9B40123 wpos=(108.443, 134.162, 69.485)` | 69.485 | 69.020 | +0.47 |
+| `cell=0xA9B40123 wpos=(109.702, 133.700, 69.485)` | 69.485 | 69.020 | +0.47 |
+
+The MISS lines from the same cottage, same physics tick rate:
+
+| MISS example | foot.W.Z | world floor Z | dz |
+|---|---:|---:|---:|
+| `cell=0xA9B40125 foot.W=(104.263, 140.893, 66.500)` | 66.500 | 66.020 | **+0.48** |
+| `cell=0xA9B40121 foot.W=(104.254, 140.441, 66.500)` | 66.500 | 66.020 | +0.48 |
+
+The **20 mm difference in foot.W.Z** (66.480 → 66.500) flips the
+synthesis from HIT to MISS. This matches the `+0.02 m` Z-bump
+mentioned in
+[TransitionTypes.cs:1511](src/AcDream.Core/Physics/TransitionTypes.cs:1511)
+("the +0.02f Z-bump applied for render z-fight prevention"). When the
+foot's world Z is at exactly the rendered floor + foot-height
+(`world_floor + 0.46`), synthesis HITs. When it's 2 cm higher,
+synthesis MISSES.
+
+That's not a probe-distance issue. The probe distance is 0.5 m and
+`dz=0.48 < 0.5`. The geometry is well within reach.
+
+**The defect is in the sphere-overlap test or sphere-plane-adjustment
+math inside `FindWalkableInternal`.** Retail anchors to compare against:
+
+- `CPolygon::walkable_hits_sphere` —
+ [`acclient_2013_pseudo_c.txt:323006-323028`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
+ Slope test + `polygon_hits_sphere_slow_but_sure` overlap test.
+- `CPolygon::adjust_sphere_to_plane` —
+ [`acclient_2013_pseudo_c.txt:322032`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
+ Sphere-to-plane projection with sweep-distance budget.
+- `BSPLEAF::find_walkable` —
+ [`acclient_2013_pseudo_c.txt:326793`](docs/research/named-retail/acclient_2013_pseudo_c.txt).
+ Iterates polys; requires BOTH `walkable_hits_sphere` AND
+ `adjust_sphere_to_plane` non-zero.
+
+Our port lives in
+[`BSPQuery.FindWalkableInternal`](src/AcDream.Core/Physics/BSPQuery.cs)
+(called by `FindWalkableSphere`). Direct line-by-line comparison
+against the retail oracle is the next step.
+
+## H1 evidence (secondary, doorway-edge cases)
+
+59 `[walk-miss]` events where the foot XY left the indoor floor poly
+but the LandCell underneath would have been walkable. All concentrated
+in cell `0xA9B40125`, whose floor poly is a tiny 1.5 m × 0.5 m strip
+(`bbox=(-0.40,-5.65)..(1.10,-5.15)`) — this is a **doorway-threshold
+cell**. The player crosses it; the foot XY exits the strip before they
+reach the next cell.
+
+Sample (last 3 walk-miss lines):
+
+```
+[walk-miss] cell=0xA9B40125 foot.W=(104.400,147.409,66.480)
+ foot.L=(0.100,-11.909,0.460) ...
+ containsFootXY=False
+ landcell.hasTerrain=true landcell.terrainZ=66.000 landcell.dz=+0.480
+```
+
+`foot.L.Y = -11.909`, well outside the strip's Y range
+`[-5.65, -5.15]`. Outdoor LandCell terrain at world Z = 66.000 would
+have grounded the foot at `dz = 0.480`. This is the case the prior
+handoff (`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`)
+diagnosed as "doorway threshold has no floor poly." It's real — but
+**3 % of the total miss volume, not the primary defect**.
+
+## H2 ruled out
+
+Of 817 in-bbox candidate misses, **792 sit at `dz` between 0.4 m and
+0.5 m**, well within the 0.5 m probe distance. Only 25 events fall in
+the 0.0–0.4 m range (a few cm above plane — already touching).
+Bumping `INDOOR_WALKABLE_PROBE_DISTANCE` will not help — the geometry
+is reachable; the rejection is in the sphere-overlap math.
+
+## Cells of interest
+
+| Cell ID | Walk-misses | Floor polys (local-XY bboxes) | Role |
+|---|---:|---|---|
+| `0xA9B40121` | 1,453 | 1 @ Z=0, bbox `(-5.7,-5.15)..(5.7,4.55)` | Cottage main room (1st floor) |
+| `0xA9B40123` | 283 | 5 @ Z=3.0 (multiple connected panels) | Cottage **2nd floor** |
+| `0xA9B40125` | 67 | 1 @ Z=0, bbox `(-0.4,-5.65)..(1.1,-5.15)` | **Doorway threshold strip** |
+| `0xA9B40126` | 11 | (no [floor-polys] dump captured at start) | Adjacent |
+
+Cell `0xA9B40123`'s floor polys all sit at `planeZ@center=3.000` —
+that's 3 m above the cell origin, i.e. a 2nd-story floor. The HITs in
+this cell at world Z 69.485 match: cell origin Z 66.020 + local floor
+Z 3.0 = world floor 69.020, foot 0.46 above → world Z 69.485. ✓
+
+This confirms our 2nd-floor handling is being **exercised** by the
+synthesis; it's just rejecting at the same 2 cm boundary as the 1st
+floor.
+
+## Disambiguation matrix verdict (per spec)
+
+| Matrix entry | Spec condition | This capture |
+|---|---|---|
+| **H1 confirmed** | `landcell.hasTerrain==true AND \|landcell.dz\| < 0.2 m` | 59 events at doorway threshold |
+| **H2 confirmed** | `containsFootXY==true AND 0.5 m < nearest.dz < 5 m` | 0 events qualify (all "candidates" turned out to be jump-arc) |
+| **H3 candidate** | `containsFootXY==true AND nearest.dz ≤ 0.5 m AND normalZ ≥ FloorZ` | **817 events** — the bulk |
+| H1+H3 combo | `containsFootXY==false AND landcell.hasTerrain==false` | 0 events |
+
+Spec matrix entry H3 is flagged as "next step: cdb attach to retail."
+Given the 2 cm hit-vs-miss boundary and the matched normalZ + FloorZ +
+in-bbox + in-probe signatures, we can attempt the retail decomp
+side-by-side comparison **first** without cdb — the discrepancy is
+narrow enough that the decomp + a focused unit test should expose it.
+cdb is a fallback if that fails.
+
+## Recommended next step
+
+**Phase: design + ship the H3 fix.**
+
+1. **Decomp comparison** (~1 hour, no code change):
+ - Read `acclient_2013_pseudo_c.txt:322032-322110` (`adjust_sphere_to_plane`)
+ and our equivalent inside `BSPQuery.FindWalkableInternal`
+ ([BSPQuery.cs](src/AcDream.Core/Physics/BSPQuery.cs)) line-by-line.
+ - Read `acclient_2013_pseudo_c.txt:323006-323028` (`walkable_hits_sphere`)
+ and our equivalent.
+ - Read `acclient_2013_pseudo_c.txt:326793-326816` (`BSPLEAF::find_walkable`)
+ and our `FindWalkableInternal` traversal.
+ - Document any divergences in a follow-up findings note.
+
+2. **Unit test for the 2 cm boundary** (~30 min):
+ - Synthetic `CellPhysics` with a horizontal floor at local Z=0.
+ - Foot sphere centered at `Z=0.46`, then again at `Z=0.48`. Assert
+ both HIT.
+ - Mirrors the IndoorWalkablePlaneTests fixture pattern.
+ - Expected: both fail at HEAD; pass after the fix.
+
+3. **Fix the divergence found in step 1** (size unknown — could be a
+ one-line epsilon adjustment or a structural mismatch).
+
+4. **Re-run this capture with the fix in place.** Expected outcome:
+ `[indoor-walkable] HIT` rate goes from 0.38 % to >95 % during
+ ground-contact frames; `[walk-miss]` H3 bucket collapses; H1 (the
+ 59 doorway-edge events) remains.
+
+5. **Then design H1 fix** as a separate, smaller phase — porting
+ retail's `CTransition::check_other_cells`
+ (`acclient_2013_pseudo_c.txt:272717`) for multi-cell BSP iteration.
+ Lower priority since 3 % of total misses and only manifests at
+ threshold strips.
+
+6. **Delete the spike** when both H3 and H1 fixes ship: revert
+ `27c7284..a2e7a87` plus this findings doc.
+
+## Anti-patterns to avoid (from prior handoffs)
+
+- **Don't increase `INDOOR_WALKABLE_PROBE_DISTANCE`.** The data shows
+ probe distance is not the blocker.
+- **Don't delete `TryFindIndoorWalkablePlane`** ("Bug A" from 2026-05-20)
+ — once H3 is fixed, the synthesis path will work correctly and is
+ the right call (not removable until retail's multi-cell iteration is
+ also ported).
+- **Don't bypass `walkable_hits_sphere` overlap rejection with a
+ looser epsilon** without first verifying retail's exact behavior at
+ this boundary. The 2 cm difference is suspiciously close to the
+ rendered Z-bump (`+0.02 f`) used to prevent z-fighting on indoor
+ floors. There may be a coordinate-space mismatch where the player's
+ foot world Z is computed in the rendered (bumped) frame but the
+ synthesis expects the dat-stated (unbumped) frame, or vice versa.
+ Investigate before "fixing."
+
+## Acceptance review
+
+The probe spike's acceptance criteria from
+[`2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md):
+
+- [x] Build green, tests green
+- [x] Live capture produced `[walk-miss]` lines at the cottage doorway
+- [x] Live capture produced `[walk-miss]` lines on the cottage 2nd floor
+- [x] Aggregated counts classify each MISS per the disambiguation matrix
+- [x] Zero `[walk-miss]` / `[floor-polys]` lines when env var unset
+ (verified by code inspection; runtime verification deferred)
+
+**Spike concluded. Ship findings, design the H3 fix.**
diff --git a/docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md b/docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md
new file mode 100644
index 0000000..8e58c0d
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md
@@ -0,0 +1,687 @@
+# Indoor walk-miss probe — 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:** Ship a diagnostic probe set (`[walk-miss]` per-miss line +
+`[floor-polys]` per-cell-load dump) that disambiguates H1/H2/H3 for
+ISSUES #83 in a single Holtburg capture session. No physics behavior
+changes.
+
+**Architecture:** One new env-gated flag in `PhysicsDiagnostics`. One
+new static aggregator (`WalkMissDiagnostic`) that's pure-function over
+a `CellPhysics.Resolved` dict. Two emission sites:
+`Transition.FindEnvCollisions` MISS branch (per-frame) and
+`PhysicsDataCache.CacheCellStruct` (one-shot per cell).
+
+**Tech Stack:** C# .NET 10, xUnit, existing `PhysicsDiagnostics` /
+`ResolvedPolygon` types.
+
+**Spec:** [`docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md`](../specs/2026-05-21-indoor-walk-miss-probe-design.md)
+
+---
+
+## File Structure
+
+| File | Action | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` | Modify | Add `ProbeWalkMissEnabled` flag (matches existing pattern). |
+| `src/AcDream.Core/Physics/WalkMissDiagnostic.cs` | Create | Pure aggregator: given a `CellPhysics.Resolved` dict + foot local position, returns the nearest walkable-poly stats. Also enumerates walkable polys for the per-cell dump. |
+| `src/AcDream.Core/Physics/TransitionTypes.cs` | Modify | Add `[walk-miss]` emission inside the existing MISS branch at the `[indoor-walkable]` log site (~line 1538). |
+| `src/AcDream.Core/Physics/PhysicsDataCache.cs` | Modify | Add `[floor-polys]` one-shot emission immediately after the existing `[cell-cache]` block (~line 220). |
+| `tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs` | Create | 3 unit tests: flag roundtrip + aggregator logic. |
+
+---
+
+## Task 1: PhysicsDiagnostics.ProbeWalkMissEnabled flag
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (add property after `ProbeContactPlaneEnabled` at line 244)
+- Create: `tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs` (new file, flag roundtrip test only)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs`:
+
+```csharp
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using System.Collections.Generic;
+using System.Numerics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Tests for the ISSUES #83 H-disambiguation probe spike (spec
+/// 2026-05-21-indoor-walk-miss-probe-design.md).
+///
+/// Covers:
+/// 1. PhysicsDiagnostics.ProbeWalkMissEnabled flag get/set roundtrip.
+/// 2. WalkMissDiagnostic.AggregateNearestWalkable selects the nearest
+/// walkable polygon by |dz| when the foot XY lies inside a poly's
+/// local XY bounding box.
+/// 3. WalkMissDiagnostic.AggregateNearestWalkable falls back to the
+/// nearest poly by |dz| when no walkable poly XY-contains the foot,
+/// reporting ContainsFootXY=false.
+///
+public class WalkMissDiagnosticTests
+{
+ [Fact]
+ public void ProbeWalkMiss_StaticApi_Roundtrip()
+ {
+ bool initial = PhysicsDiagnostics.ProbeWalkMissEnabled;
+ try
+ {
+ PhysicsDiagnostics.ProbeWalkMissEnabled = true;
+ Assert.True(PhysicsDiagnostics.ProbeWalkMissEnabled);
+
+ PhysicsDiagnostics.ProbeWalkMissEnabled = false;
+ Assert.False(PhysicsDiagnostics.ProbeWalkMissEnabled);
+ }
+ finally
+ {
+ PhysicsDiagnostics.ProbeWalkMissEnabled = initial;
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WalkMissDiagnosticTests"`
+
+Expected: FAIL with "PhysicsDiagnostics does not contain a definition for ProbeWalkMissEnabled".
+
+- [ ] **Step 3: Add the flag to PhysicsDiagnostics**
+
+Edit `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`. Insert after the
+`ProbeContactPlaneEnabled` property (line 244) and before the
+`LogCpBoolWrite` helper (line 246):
+
+```csharp
+ ///
+ /// Indoor walking ISSUES #83 H-disambiguation spike (2026-05-21).
+ /// When true, two diagnostic emissions activate:
+ ///
+ /// - One [walk-miss] line per
+ /// MISS
+ /// event, dumping foot world/local position, the nearest
+ /// walkable polygon in the cell (with XY-containment flag and
+ /// vertical gap), and whether the LandCell terrain at the same
+ /// XY would have grounded the player.
+ /// - One [floor-polys] line per indoor
+ /// cell cached, enumerating each walkable-eligible polygon's
+ /// id, normal Z, local-XY bounding box, and plane Z at the
+ /// bbox center.
+ ///
+ /// Together these answer H1 (multi-cell iteration missing) vs H2
+ /// (probe distance too short) vs H3 (poly absent /
+ /// walkable_hits_sphere rejection) for the ISSUES #83
+ /// stuck-falling bug. Spike-only — remove once the root cause is
+ /// identified and the fix lands.
+ ///
+ ///
+ /// Initial state from ACDREAM_PROBE_WALK_MISS=1.
+ /// No DebugPanel mirror — one-shot diagnostic.
+ ///
+ ///
+ ///
+ /// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md.
+ ///
+ ///
+ public static bool ProbeWalkMissEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_MISS") == "1";
+
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WalkMissDiagnosticTests"`
+
+Expected: PASS — 1 test passing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs
+git commit -m "$(cat <<'EOF'
+feat(physics): ProbeWalkMissEnabled flag for ISSUES #83 H-disambiguation
+
+Adds a new diagnostic flag for the indoor-walking walk-miss probe
+spike per docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md.
+Env var ACDREAM_PROBE_WALK_MISS=1, runtime-toggleable via property.
+No DebugPanel mirror — spike-only. Following commits wire the
+[walk-miss] and [floor-polys] emissions to this flag.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: WalkMissDiagnostic aggregator
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/WalkMissDiagnostic.cs`
+- Modify: `tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs` (add 2 aggregator tests)
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs`,
+inside the class body (after `ProbeWalkMiss_StaticApi_Roundtrip`):
+
+```csharp
+ private static ResolvedPolygon MakeFloorPoly(
+ Vector3 v00, Vector3 v10, Vector3 v11, Vector3 v01)
+ {
+ var verts = new[] { v00, v10, v11, v01 };
+ var normal = Vector3.Normalize(Vector3.Cross(v10 - v00, v01 - v00));
+ float d = -Vector3.Dot(normal, v00);
+ return new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new System.Numerics.Plane(normal, d),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+ }
+
+ ///
+ /// Foot at (0,0,1). Two walkable polys: a low one at Z=0 (foot is
+ /// 1 m above) and a high one at Z=0.8 (foot is 0.2 m above).
+ /// Aggregator picks the high one — smaller |dz|.
+ ///
+ [Fact]
+ public void AggregateNearestWalkable_PicksNearestByDz_WhenFootXYInsideMultiplePolys()
+ {
+ var lowFloor = MakeFloorPoly(
+ new Vector3(-5f, -5f, 0f),
+ new Vector3( 5f, -5f, 0f),
+ new Vector3( 5f, 5f, 0f),
+ new Vector3(-5f, 5f, 0f));
+ var highFloor = MakeFloorPoly(
+ new Vector3(-2f, -2f, 0.8f),
+ new Vector3( 2f, -2f, 0.8f),
+ new Vector3( 2f, 2f, 0.8f),
+ new Vector3(-2f, 2f, 0.8f));
+
+ var resolved = new Dictionary
+ {
+ [1] = lowFloor,
+ [2] = highFloor,
+ };
+
+ var result = WalkMissDiagnostic.AggregateNearestWalkable(
+ resolved,
+ footLocal: new Vector3(0f, 0f, 1f),
+ floorZ: PhysicsGlobals.FloorZ);
+
+ Assert.True(result.Found);
+ Assert.Equal((ushort)2, result.PolyId);
+ Assert.True(result.ContainsFootXY);
+ Assert.Equal(0.2f, result.Dz, precision: 5);
+ Assert.Equal(1.0f, result.NormalZ, precision: 5);
+ }
+
+ ///
+ /// Foot at (10,10,1) — outside both poly XY bboxes. Aggregator
+ /// returns the poly with smallest |dz| but with ContainsFootXY=false.
+ ///
+ [Fact]
+ public void AggregateNearestWalkable_FallsBackByDz_WhenFootXYOutsideAllBboxes()
+ {
+ var poly = MakeFloorPoly(
+ new Vector3(-1f, -1f, 0.5f),
+ new Vector3( 1f, -1f, 0.5f),
+ new Vector3( 1f, 1f, 0.5f),
+ new Vector3(-1f, 1f, 0.5f));
+
+ var resolved = new Dictionary { [42] = poly };
+
+ var result = WalkMissDiagnostic.AggregateNearestWalkable(
+ resolved,
+ footLocal: new Vector3(10f, 10f, 1f),
+ floorZ: PhysicsGlobals.FloorZ);
+
+ Assert.True(result.Found);
+ Assert.Equal((ushort)42, result.PolyId);
+ Assert.False(result.ContainsFootXY);
+ Assert.Equal(0.5f, result.Dz, precision: 5);
+ }
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WalkMissDiagnosticTests"`
+
+Expected: FAIL — 2 new tests fail with "The name 'WalkMissDiagnostic' does not exist".
+
+- [ ] **Step 3: Create WalkMissDiagnostic.cs with the aggregator**
+
+Create `src/AcDream.Core/Physics/WalkMissDiagnostic.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// ISSUES #83 H-disambiguation spike (2026-05-21). Pure-function
+/// aggregator over a dict — picks
+/// the nearest walkable-eligible polygon to a given foot position
+/// (cell-local space) and reports XY-containment + vertical gap so
+/// the [walk-miss] emission site can disambiguate H1/H2/H3
+/// without re-walking the dictionary itself.
+///
+///
+/// Also enumerates walkable polygons for the one-shot
+/// [floor-polys] dump at cell-cache time.
+///
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md.
+///
+///
+public static class WalkMissDiagnostic
+{
+ public readonly struct AggregateResult
+ {
+ public bool Found { get; init; }
+ public ushort PolyId { get; init; }
+ public bool ContainsFootXY { get; init; }
+ public float Dz { get; init; }
+ public float NormalZ { get; init; }
+ }
+
+ public readonly struct WalkableEntry
+ {
+ public ushort PolyId { get; init; }
+ public float NormalZ { get; init; }
+ public Vector3 BboxMin { get; init; }
+ public Vector3 BboxMax { get; init; }
+ public float PlaneZAtBboxCenter { get; init; }
+ }
+
+ ///
+ /// Walks , considering only polygons
+ /// whose plane normal Z is at least
+ /// (walkable slope). Selection rule:
+ ///
+ /// - Polygons whose local-XY bounding box contains
+ /// 's XY are preferred. Among them,
+ /// the one with smallest |dz| wins.
+ /// - If no poly contains the foot XY, the poly
+ /// with smallest |dz| across all walkable polys wins,
+ /// and is false.
+ ///
+ ///
+ public static AggregateResult AggregateNearestWalkable(
+ IReadOnlyDictionary resolved,
+ Vector3 footLocal,
+ float floorZ)
+ {
+ bool bestFound = false;
+ bool bestContainsFootXY = false;
+ ushort bestPolyId = 0;
+ float bestAbsDz = float.MaxValue;
+ float bestSignedDz = 0f;
+ float bestNormalZ = 0f;
+
+ foreach (var kvp in resolved)
+ {
+ var poly = kvp.Value;
+ if (poly.Plane.Normal.Z < floorZ) continue;
+ if (poly.Vertices.Length < 3) continue;
+
+ // Local-XY bounding box.
+ float minX = float.MaxValue, minY = float.MaxValue;
+ float maxX = float.MinValue, maxY = float.MinValue;
+ for (int i = 0; i < poly.Vertices.Length; i++)
+ {
+ var v = poly.Vertices[i];
+ if (v.X < minX) minX = v.X;
+ if (v.Y < minY) minY = v.Y;
+ if (v.X > maxX) maxX = v.X;
+ if (v.Y > maxY) maxY = v.Y;
+ }
+ bool containsFootXY =
+ footLocal.X >= minX && footLocal.X <= maxX &&
+ footLocal.Y >= minY && footLocal.Y <= maxY;
+
+ // Signed vertical gap from foot to the polygon's plane at
+ // the foot's XY: plane.D + n.x*X + n.y*Y + n.z*Z = 0
+ // ⇒ planeZ = -(D + n.x*X + n.y*Y) / n.z
+ // ⇒ dz = footZ - planeZ
+ float planeZ = -(poly.Plane.D
+ + poly.Plane.Normal.X * footLocal.X
+ + poly.Plane.Normal.Y * footLocal.Y)
+ / poly.Plane.Normal.Z;
+ float signedDz = footLocal.Z - planeZ;
+ float absDz = System.MathF.Abs(signedDz);
+
+ // Preference: prefer XY-containing polys. Among the
+ // preferred set, smallest |dz| wins.
+ bool preferOver = !bestFound
+ || (containsFootXY && !bestContainsFootXY)
+ || (containsFootXY == bestContainsFootXY && absDz < bestAbsDz);
+
+ if (preferOver)
+ {
+ bestFound = true;
+ bestContainsFootXY = containsFootXY;
+ bestPolyId = kvp.Key;
+ bestAbsDz = absDz;
+ bestSignedDz = signedDz;
+ bestNormalZ = poly.Plane.Normal.Z;
+ }
+ }
+
+ return new AggregateResult
+ {
+ Found = bestFound,
+ PolyId = bestPolyId,
+ ContainsFootXY = bestContainsFootXY,
+ Dz = bestSignedDz,
+ NormalZ = bestNormalZ,
+ };
+ }
+
+ ///
+ /// Enumerates walkable-eligible polygons (normal Z >= floorZ)
+ /// with their local-XY bounding boxes and plane Z at the bbox
+ /// center. Used by the one-shot [floor-polys] cell-load
+ /// dump.
+ ///
+ public static IEnumerable EnumerateWalkable(
+ IReadOnlyDictionary resolved,
+ float floorZ)
+ {
+ foreach (var kvp in resolved)
+ {
+ var poly = kvp.Value;
+ if (poly.Plane.Normal.Z < floorZ) continue;
+ if (poly.Vertices.Length < 3) continue;
+
+ float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
+ float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
+ for (int i = 0; i < poly.Vertices.Length; i++)
+ {
+ var v = poly.Vertices[i];
+ if (v.X < minX) minX = v.X;
+ if (v.Y < minY) minY = v.Y;
+ if (v.Z < minZ) minZ = v.Z;
+ if (v.X > maxX) maxX = v.X;
+ if (v.Y > maxY) maxY = v.Y;
+ if (v.Z > maxZ) maxZ = v.Z;
+ }
+
+ float cx = (minX + maxX) * 0.5f;
+ float cy = (minY + maxY) * 0.5f;
+ float planeZAtCenter = -(poly.Plane.D
+ + poly.Plane.Normal.X * cx
+ + poly.Plane.Normal.Y * cy)
+ / poly.Plane.Normal.Z;
+
+ yield return new WalkableEntry
+ {
+ PolyId = kvp.Key,
+ NormalZ = poly.Plane.Normal.Z,
+ BboxMin = new Vector3(minX, minY, minZ),
+ BboxMax = new Vector3(maxX, maxY, maxZ),
+ PlaneZAtBboxCenter = planeZAtCenter,
+ };
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify all 3 pass**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WalkMissDiagnosticTests"`
+
+Expected: PASS — 3 tests passing.
+
+- [ ] **Step 5: Run the full Core test suite for regression**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
+
+Expected: PASS — no new failures vs. the pre-change baseline. (The
+pre-existing 8-failure physics baseline mentioned in the L.2a slice 3
+ship notes is the floor; we should not introduce new failures.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/WalkMissDiagnostic.cs tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs
+git commit -m "$(cat <<'EOF'
+feat(physics): WalkMissDiagnostic aggregator for ISSUES #83 probe spike
+
+Pure-function aggregator that, given a CellPhysics.Resolved dict and
+a foot local position, picks the nearest walkable-eligible polygon
+(normal Z >= FloorZ) and reports XY-containment + signed vertical gap.
+Also enumerates walkable polys with local-XY bboxes for the one-shot
+[floor-polys] cell-load dump.
+
+Pure-function, no behavior change. Wiring to emission sites lands in
+the next commit.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: Wire emission sites — `[walk-miss]` + `[floor-polys]`
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (~line 1538, inside the existing MISS branch)
+- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` (~line 220, immediately after the existing `[cell-cache]` block)
+
+No new tests — Task 2 covers the aggregator logic; the emission lines
+themselves are verified by the live capture per the spec's acceptance
+criteria.
+
+- [ ] **Step 1: Add the `[walk-miss]` emission to TransitionTypes.cs**
+
+Edit `src/AcDream.Core/Physics/TransitionTypes.cs`. The MISS branch
+already emits `[indoor-walkable] ... result=MISS` at ~line 1538.
+Immediately after that `Console.WriteLine` (still inside the
+`if (PhysicsDiagnostics.ProbeIndoorBspEnabled) { ... else { ... }}`
+block but *outside* its enclosing scope so it doesn't depend on the
+`ProbeIndoorBspEnabled` flag), add a new block guarded by
+`ProbeWalkMissEnabled`.
+
+Find this code at ~line 1525-1541:
+
+```csharp
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ {
+ if (walkableHit)
+ {
+ // dz = signed gap between foot and synthesized plane.
+ // ...
+ float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} ..."));
+ }
+ else
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS"));
+ }
+ }
+```
+
+Add directly after the closing `}` of the outer `if (PhysicsDiagnostics.ProbeIndoorBspEnabled)` block (before `if (walkableHit) { return ValidateWalkable(...); }` at line 1543):
+
+```csharp
+ if (!walkableHit && PhysicsDiagnostics.ProbeWalkMissEnabled)
+ {
+ var agg = WalkMissDiagnostic.AggregateNearestWalkable(
+ cellPhysics.Resolved,
+ footLocal: localCenter,
+ floorZ: PhysicsGlobals.FloorZ);
+
+ // Count walkable polys for the line (cheap re-scan; the
+ // probe is opt-in so cost is bounded to MISS frames).
+ int walkableCount = 0;
+ foreach (var kvp in cellPhysics.Resolved)
+ {
+ if (kvp.Value.Plane.Normal.Z >= PhysicsGlobals.FloorZ
+ && kvp.Value.Vertices.Length >= 3)
+ walkableCount++;
+ }
+
+ // Outdoor terrain probe at the same world XY — the
+ // "would multi-cell iteration have grounded us?" check.
+ var terrain = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
+ string terrainPart;
+ if (terrain is null)
+ {
+ terrainPart = "landcell.hasTerrain=false";
+ }
+ else
+ {
+ var tp = terrain.Value.Plane;
+ float terrainZ = -(tp.D + tp.Normal.X * footCenter.X
+ + tp.Normal.Y * footCenter.Y)
+ / tp.Normal.Z;
+ float terrainDz = footCenter.Z - terrainZ;
+ terrainPart = System.FormattableString.Invariant(
+ $"landcell.hasTerrain=true landcell.terrainZ={terrainZ:F3} landcell.dz={terrainDz:+0.000;-0.000;+0.000}");
+ }
+
+ string nearestPart = agg.Found
+ ? System.FormattableString.Invariant(
+ $"nearest.polyId=0x{agg.PolyId:X4} nearest.containsFootXY={agg.ContainsFootXY} nearest.dz={agg.Dz:+0.000;-0.000;+0.000} nearest.normalZ={agg.NormalZ:F3}")
+ : "nearest=none";
+
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[walk-miss] cell=0x{sp.CheckCellId:X8} foot.W=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) foot.L=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) floorPolyCount={walkableCount} {nearestPart} {terrainPart}"));
+ }
+```
+
+- [ ] **Step 2: Add the `[floor-polys]` emission to PhysicsDataCache.cs**
+
+Edit `src/AcDream.Core/Physics/PhysicsDataCache.cs`. Immediately after
+the closing `}` of the existing `if (PhysicsDiagnostics.ProbeCellCacheEnabled)`
+block at ~line 220 (the line ending with `worldOrigin=(...)"));`), add a new block:
+
+```csharp
+ if (PhysicsDiagnostics.ProbeWalkMissEnabled)
+ {
+ int walkableCount = 0;
+ foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
+ resolved, PhysicsGlobals.FloorZ))
+ walkableCount++;
+
+ Console.Write(System.FormattableString.Invariant(
+ $"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}"));
+ foreach (var entry in WalkMissDiagnostic.EnumerateWalkable(
+ resolved, PhysicsGlobals.FloorZ))
+ {
+ Console.Write(System.FormattableString.Invariant(
+ $" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]"));
+ }
+ Console.WriteLine();
+ }
+```
+
+- [ ] **Step 3: Build the project**
+
+Run: `dotnet build`
+
+Expected: PASS — no compile errors.
+
+- [ ] **Step 4: Run the full Core test suite for regression**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
+
+Expected: PASS — no new failures vs. the pre-change baseline.
+
+- [ ] **Step 5: Smoke-check zero-cost gate**
+
+Run a brief 5-second `dotnet run` launch (or rely on developer eyeball)
+with `ACDREAM_PROBE_WALK_MISS` *unset* and confirm no `[walk-miss]` /
+`[floor-polys]` lines appear in stdout. Skip this step if user
+explicitly asks to skip to the live capture.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/TransitionTypes.cs src/AcDream.Core/Physics/PhysicsDataCache.cs
+git commit -m "$(cat <<'EOF'
+feat(physics): [walk-miss] + [floor-polys] diagnostic emissions
+
+Wires the WalkMissDiagnostic aggregator + flag into the two emission
+sites per docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md.
+
+- [walk-miss] (per-frame, MISS branch of TryFindIndoorWalkablePlane):
+ foot world+local position, nearest walkable poly with XY-containment
+ flag and vertical gap, and LandCell terrain probe at the same XY.
+- [floor-polys] (one-shot per cell at cache time): walkable poly id,
+ normal Z, local-XY bbox, plane Z at bbox center.
+
+Both gated on ACDREAM_PROBE_WALK_MISS=1. No physics behavior changes.
+The live capture at the Holtburg cottage doorway + inn 2nd floor +
+cellar descent disambiguates H1 (multi-cell iteration), H2 (probe
+distance), H3 (poly absent / walkable_hits_sphere rejection) for
+ISSUES #83.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Live capture (manual — outside the plan's automated scope)
+
+The probe is now ready. To collect data:
+
+```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_DEVTOOLS = "1"
+$env:ACDREAM_PROBE_WALK_MISS = "1"
+$env:ACDREAM_PROBE_INDOOR_BSP = "1"
+$env:ACDREAM_PROBE_CELL_CACHE = "1"
+dotnet build -c Debug
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-walk-miss.log"
+```
+
+Scenarios to walk:
+1. Cottage doorway threshold (cross in, cross out).
+2. Holtburg inn ground → upper-floor stairs → upper-floor edge walk.
+3. Cellar descent (or any single-floor → lower-floor stair pair).
+
+Then convert log to UTF-8 for grep:
+
+```powershell
+Get-Content launch-walk-miss.log -Encoding Unicode |
+ Out-File launch-walk-miss.utf8.log -Encoding utf8
+```
+
+Aggregate the `[walk-miss]` lines, classify per the disambiguation
+matrix in the spec, write up findings in
+`docs/research/2026-05-21-walk-miss-capture-findings.md`. The fix
+design happens in a separate follow-up session.
+
+---
+
+## Self-review checklist
+
+- [x] **Spec coverage**: each spec component (flag, aggregator, two emissions, three tests) maps to a task step.
+- [x] **No placeholders**: every step has the exact file path + the exact code to insert.
+- [x] **Type consistency**: `AggregateResult` / `WalkableEntry` / property names match across Task 2 definition and Task 3 usage. `WalkMissDiagnostic.AggregateNearestWalkable` signature stable.
+- [x] **Test names match spec**: `AggregateNearestWalkable_PicksNearestByDz_WhenFootXYInsideMultiplePolys` covers spec test 2 (renamed for clarity); `AggregateNearestWalkable_FallsBackByDz_WhenFootXYOutsideAllBboxes` covers spec test 3.
+- [x] **Commits are atomic**: 3 commits, each green-tests + green-build at HEAD.
+- [x] **Acceptance criteria**: live capture is Task 4 (manual), spec criteria 1-2 covered by Task 3 step 3+4, criterion 4 covered by step 5.
diff --git a/docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md b/docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md
new file mode 100644
index 0000000..2203266
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md
@@ -0,0 +1,218 @@
+# Indoor walk-miss probe (ISSUES #83 H-disambiguation spike)
+
+**Date:** 2026-05-21
+**Status:** Spec — awaiting user review before plan-writing.
+**Phase:** Indoor walking, ISSUES #83 next step. Spike-only — no behavior changes.
+**Author:** Claude Opus 4.7.
+
+## Summary
+
+Indoor walking glitches at floor-poly edges and doorway thresholds
+(stuck-falling animation). The previous session's investigation
+(`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`) narrowed
+the open question to: **when `TryFindIndoorWalkablePlane` returns MISS,
+why?** Three live hypotheses (H1 multi-cell iteration missing, H2 probe
+distance too short, H3 floor poly absent / `walkable_hits_sphere`
+rejection). A single composite probe answers all three in one capture
+session. **No physics behavior changes.** The fix is designed in a
+follow-up session after the probe data points at the right hypothesis.
+
+## Goal
+
+Add a diagnostic probe set that, at a single Holtburg test run covering
+doorway crossing + 2nd-floor edge + cellar descent, produces enough
+per-MISS evidence to pick between H1/H2/H3 without attaching cdb to
+retail.
+
+## What the probe must capture
+
+For each `[indoor-walkable] ... result=MISS` event, emit a paired
+`[walk-miss]` line with:
+
+| Field | Purpose |
+|---|---|
+| `cellId` | Cross-reference with the cell-load dump. |
+| `foot.W` (world XY,Z) | Where the foot sphere is. |
+| `foot.L` (cell-local XY,Z) | Cell-local position used by the BSP. |
+| `floorPolyCount` | How many walkable-eligible polys this cell holds. |
+| `nearest.polyId` | Poly id of the closest walkable poly (XY-overlapping foot, then nearest by `dz`). |
+| `nearest.containsFootXY` | `true` if foot XY is inside the poly's local-XY bounding box. |
+| `nearest.dz` | Signed vertical distance from foot to the poly plane (positive = foot above poly). |
+| `nearest.normalZ` | Normal Z of the poly (high = horizontal floor, low = steep ramp). |
+| `landcell.hasTerrain` | Does `engine.SampleTerrainWalkable(foot.X, foot.Y)` return non-null? |
+| `landcell.terrainZ` | If yes: the terrain plane Z at that XY (compute as `-D/normal.Z`). |
+| `landcell.dz` | `foot.Z - landcell.terrainZ` (positive = foot above terrain). |
+
+Once-per-cell, when `CellPhysics` is cached, emit a `[floor-polys]`
+dump:
+
+| Field | Purpose |
+|---|---|
+| `cellId` | The envCell. |
+| `walkableCount` | Polys with `Plane.Normal.Z >= FloorZ`. |
+| Per walkable poly: `id, normalZ, bboxLocalXY, planeZAtBboxCenter` | Lets us reconstruct floor coverage offline. |
+
+Both gated on a single new `ProbeWalkMissEnabled` flag.
+
+## Disambiguation matrix (after the run)
+
+Aggregate the `[walk-miss]` lines from the capture, then:
+
+- **`landcell.hasTerrain == true` AND `abs(landcell.dz) < 0.2 m`** → **H1
+ confirmed.** Multi-cell iteration would have grounded the player on the
+ outdoor LandCell's terrain at the threshold. Fix: port retail's
+ `check_other_cells` loop.
+- **`nearest.containsFootXY == true` AND `nearest.dz > 0.5 m` AND
+ `nearest.dz < 5 m`** → **H2 confirmed.** Floor poly XY-overlaps the
+ foot but sits below the 0.5 m probe distance. Fix: increase
+ `INDOOR_WALKABLE_PROBE_DISTANCE` (with the retail-faithful constant
+ pulled from `acclient_2013_pseudo_c.txt` or the OBJECTINFO step-down
+ height).
+- **`nearest.containsFootXY == true` AND `nearest.dz <= 0.5 m` AND
+ `nearest.normalZ >= FloorZ`** → **H3 candidate.** Poly is XY-aligned,
+ within the probe, walkable-slope — should hit but doesn't. Next step:
+ cdb attach to retail at `BSPLEAF::find_walkable` to compare poly iter
+ with ours.
+- **`nearest.containsFootXY == false` AND `landcell.hasTerrain == false`**
+ → **H1+H3 combination.** Both indoor and outdoor have no floor here;
+ retail must do something we haven't found. cdb attach required.
+
+## Components
+
+### 1. `PhysicsDiagnostics.ProbeWalkMissEnabled`
+
+New static property in `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`,
+matching the existing pattern (`ProbeIndoorBspEnabled`, etc):
+
+- Env var: `ACDREAM_PROBE_WALK_MISS=1`.
+- Runtime-toggleable via property setter.
+- No DebugPanel mirror — one-shot diagnostic, not a persistent toggle.
+
+### 2. Per-MISS `[walk-miss]` log line
+
+In `Transition.FindEnvCollisions` (`TransitionTypes.cs:1538` — the
+existing MISS branch of `TryFindIndoorWalkablePlane`'s caller). When
+`ProbeWalkMissEnabled`:
+
+1. Walk `cellPhysics.Resolved` for walkable-eligible polys (where
+ `poly.Plane.Normal.Z >= PhysicsGlobals.FloorZ`).
+2. For each: compute the XY bbox from `poly.Vertices`, compute the
+ plane Z at the foot's local XY (`Z = (-D - normal.X*X - normal.Y*Y) /
+ normal.Z`), track the one with smallest `|dz|` where foot XY ∈ bbox
+ (tiebreaker: smallest `dz` across all polys).
+3. Call `engine.SampleTerrainWalkable(foot.X, foot.Y)` for the LandCell
+ probe.
+4. Emit the line.
+
+Zero cost when flag is off (single bool check guards the whole block).
+
+### 3. One-shot `[floor-polys]` cell-load dump
+
+In `PhysicsDataCache.CacheCellStruct` (immediately after the existing
+`[cell-cache]` block at `PhysicsDataCache.cs:182`). When
+`ProbeWalkMissEnabled`:
+
+- For each `poly` in `resolved` where `Normal.Z >= FloorZ`: log id,
+ normalZ, bbox, `planeZAtCenter`.
+- Aggregate into one line if walkableCount ≤ 6, else multi-line.
+
+Fires once per cell — `_cellStruct` is keyed by id with `ConcurrentDictionary` so a second cache call is a no-op (existing path).
+
+### 4. Test coverage
+
+`tests/AcDream.Core.Tests/Physics/IndoorWalkMissProbeTests.cs`:
+
+- `ProbeWalkMiss_StaticApi_Roundtrip` — flag get/set roundtrip with
+ finally-restore (mirrors `PhysicsDiagnosticsTests`).
+- `WalkMissDiagnostic_SelectsNearestWalkablePoly_WhenFootXYInsideBbox`
+ — given a `CellPhysics` with two walkable polys at different Z, the
+ per-MISS aggregator picks the one closest by `dz`. Uses the same
+ `IndoorWalkablePlaneTests` fixture style.
+- `WalkMissDiagnostic_FallsBackToNearestPolyByDz_WhenFootXYOutsideAllBboxes`
+ — given a foot XY outside every floor-poly bbox, the aggregator
+ reports `containsFootXY=false` and returns the nearest by `dz` for
+ context (or null if no walkable polys exist).
+
+The actual log-format roundtrip (capturing stdout) is not unit-tested —
+that's verified by the live capture per acceptance criterion below.
+
+## Acceptance criteria
+
+1. `dotnet build` green.
+2. `dotnet test` green (existing tests untouched + 3 new tests).
+3. A live capture at Holtburg with the env var on produces:
+ - `[floor-polys]` lines for every loaded indoor cell.
+ - At least one `[walk-miss]` line at the cottage doorway threshold.
+ - At least one `[walk-miss]` line at an inn 2nd-floor edge.
+ - Aggregated counts let us classify each MISS into H1/H2/H3 per the
+ disambiguation matrix.
+4. When `ACDREAM_PROBE_WALK_MISS` is unset, normal play produces zero
+ `[walk-miss]` / `[floor-polys]` lines (zero-cost gate).
+
+## Out of scope
+
+- No fix to the underlying glitch — that's a follow-up session after the
+ probe data lands.
+- No changes to `TryFindIndoorWalkablePlane`, `FindWalkableSphere`,
+ `BSPQuery.FindCollisions`, or any physics behavior.
+- No retail cdb attach — that's the H3-only fallback path.
+- No new DebugPanel checkbox — keep the probe lean.
+- No removal of the existing `[indoor-bsp]` / `[indoor-walkable]` /
+ `[cp-write]` probes — they're complementary.
+
+## Risks
+
+- **R1: `[floor-polys]` dump blows up the log.** Holtburg has hundreds
+ of loaded cells. Per cell, typical walkable poly count is 1-4
+ (cottages) up to ~10 (inn ground floor). Worst-case dump volume ~3 KB
+ per cell × 200 cells = ~600 KB. Acceptable.
+ *Mitigation:* none needed at this scale.
+
+- **R2: Per-MISS line at 99.87% miss rate floods the log.** A 60-second
+ capture at 30 Hz with one MISS per tick = ~1800 lines. At ~300 bytes
+ each ≈ 540 KB. Acceptable. The user already produced 51k-line cp-write
+ logs without issue.
+ *Mitigation:* none needed.
+
+- **R3: The probe doesn't disambiguate — the data falls into the
+ "H1+H3 combination" cell.** Then we attach cdb to retail and gather
+ ground truth. This is the planned fallback per CLAUDE.md "Retail
+ debugger toolchain" section.
+ *Mitigation:* the disambiguation matrix has an entry for this case.
+
+- **R4: The probe finds the answer is *also* in a code path we haven't
+ read yet** (e.g. `precipice_slide`, `cliff_slide`, `step_up_slide`).
+ Then the next session's design exercises a new decomp read.
+ *Mitigation:* unavoidable — the probe is the cheapest first move.
+
+## Why this is the right next step
+
+- **Falsifiable in one capture** — no iterative back-and-forth.
+- **No behavior change** — can't regress the M1 demo paths (door open,
+ pickup, NPC click).
+- **Cheap implementation** — ~80 lines of code, 3 tests, one commit.
+- **The previous session's premature design lesson (Bug A) explicitly
+ flagged "probe-first, design-second" as the rule.** This is that rule
+ applied.
+
+## References
+
+- `docs/research/2026-05-20-indoor-walking-bug-a-handoff.md` — the
+ comprehensive previous-session handoff that motivated this spec.
+- `docs/research/2026-05-21-indoor-walking-doorway-investigation-prompt.md`
+ — the pickup brief.
+- `docs/superpowers/specs/2026-05-20-indoor-bsp-worldorigin-fix-design.md`
+ — Bug B (shipped), the immediately-prior spec in the series.
+- Retail decomp anchors:
+ - `acclient_2013_pseudo_c.txt:272717-272798` —
+ `CTransition::check_other_cells` (H1 candidate).
+ - `:323725-323939` — `BSPTREE::find_collisions` (H3 candidate).
+ - `:323006-323028` — `CPolygon::walkable_hits_sphere` (H3 candidate).
+- Code anchors:
+ - `src/AcDream.Core/Physics/TransitionTypes.cs:1294` —
+ `TryFindIndoorWalkablePlane`.
+ - `src/AcDream.Core/Physics/TransitionTypes.cs:1538` — MISS log site.
+ - `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:223` —
+ `ProbeCellCacheEnabled` (template for new flag).
+ - `src/AcDream.Core/Physics/PhysicsDataCache.cs:182` — `[cell-cache]`
+ pattern (template for `[floor-polys]`).