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