From bb1e919ef2e06a287134a3b49bf2de75f4bf2387 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 20 May 2026 11:00:11 +0200 Subject: [PATCH] docs(physics): spec + plan + findings for ISSUES #83 walk-miss probe spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three docs from the indoor walk-miss probe spike landed in commits 27c7284..a2e7a87: - Spec: design of the [walk-miss] + [floor-polys] diagnostic emissions with the H1/H2/H3 disambiguation matrix. - Plan: 3-task TDD implementation plan (flag, aggregator, emissions). - Findings: live-capture analysis showing H3 (walkable_hits_sphere / adjust_sphere_to_plane synthesis rejection) is the dominant defect. 817 of 876 ground-contact misses (93%) cluster at dz~0.48 m, while the 7 HITs all sit at dz~0.46 m — a 2 cm boundary between working and broken that points at the sphere-overlap math, not the probe distance. H1 (multi-cell iteration missing) is real but only 3% of misses, secondary. H2 (probe distance) ruled out. Next step: line-by-line decomp comparison of FindWalkableInternal / walkable_hits_sphere / adjust_sphere_to_plane against retail at acclient_2013_pseudo_c.txt:322032 / :323006 / :326793, then design the fix in a follow-up session. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-21-walk-miss-capture-findings.md | 252 +++++++ .../2026-05-21-indoor-walk-miss-probe.md | 687 ++++++++++++++++++ ...026-05-21-indoor-walk-miss-probe-design.md | 218 ++++++ 3 files changed, 1157 insertions(+) create mode 100644 docs/research/2026-05-21-walk-miss-capture-findings.md create mode 100644 docs/superpowers/plans/2026-05-21-indoor-walk-miss-probe.md create mode 100644 docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md 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]`).