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