# A6.P3 #98 — Comparison harness shipped, root cause identified **Session:** 2026-05-23 evening (continuation of full-day session) **Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c` **Branch:** `claude/strange-albattani-3fc83c` Read this AFTER the morning's handoff doc ([`2026-05-23-a6-p3-issue98-harness-handoff.md`](2026-05-23-a6-p3-issue98-harness-handoff.md)) — this picks up from "Option A: build the side-by-side comparison harness" and documents the FIRST evidence-driven step in the saga. --- ## TL;DR - **Evidence-driven apparatus shipped.** `PhysicsResolveCapture` writes one JSON Lines record per player ResolveWithTransition call when `ACDREAM_CAPTURE_RESOLVE=` is set. 41,228 records from a single cellar-walk session. - **Comparison test reproduces the cap divergence on the first try.** The new `LiveCompare_*` tests in `CellarUpTrajectoryReplayTests.cs` load three representative records (spawn, on-ramp, first-cap) and replay them through the harness engine. Spawn + on-ramp PASS bit-perfect; first-cap FAILS with a clear divergence — the right divergence. - **Root cause identified: the cottage GfxObj is missing from the harness.** Live cap attributes the blocking entity to `obj=0xA9B47900` — a landblock-baked static building. The cottage's floor polygons live in this GfxObj's polygon table (registered as a ShadowEntry), NOT in any cottage CELL. The harness's `RegisterStairRampGfxObj` already models the cottage's RAMP polygon but is commented out and only covers one polygon. We need the full cottage GfxObj polygon set. - **Not a step-up / AdjustOffset bug.** The head sphere (top at Z=foot+1.2) hits the cottage floor at Z=94.0 from BELOW. Math: cap at foot Z=92.74 matches 94.0 − 1.2 = 92.80. Confirmed by user reporting same cap when JUMPING in the cellar (purely vertical motion). The retail comparison question is now "why doesn't retail block the head sphere there?" — likely cottage GfxObj polygon side-mode + retail's BSP query treats single-sided polygons correctly. --- ## What ran this session (chronological, 3 commits) | Commit | What | |---|---| | `fb5fba6` | Apparatus: `PhysicsResolveCapture` static class + JSON Lines writer + body snapshot record + capture probe in `ResolveWithTransition` + smoke tests (capture writes when IsPlayer + enabled, skips otherwise) | | `44614ab` | Comparison test: 3 fixture records sampled from live capture + 3 `LiveCompare_*` tests + diagnostic dump that prints cell polygons in world frame | | `0f2db62` | Converted FirstCap test to documents-the-bug pattern (passes while harness lacks cottage GfxObj; fails when added) | Live capture launches: - `launch-a6-issue98-capture.ps1` — first capture run (no probes beyond cell-transit). Produced `a6-issue98-resolve-capture.jsonl` (12 MB, 5789 records when checked mid-session, finished at 91 MB / 41,228 records). - `launch-a6-issue98-polydump.ps1` — second capture with `ACDREAM_PROBE_POLY_DUMP`, `ACDREAM_PROBE_PUSH_BACK`, `ACDREAM_PROBE_RESOLVE`, `ACDREAM_PROBE_INDOOR_BSP`, and `ACDREAM_DUMP_CELLS` covering 0xA9B40140-0xA9B4014F. Produced `a6-issue98-resolve-capture-2.jsonl` (135 MB, 70,572 records) plus 16 cell-dump JSON fixtures and a launch log with 214 [poly-dump] entries. --- ## The apparatus (committed code) ### `PhysicsResolveCapture` ([`src/AcDream.Core/Physics/PhysicsResolveCapture.cs`](../../src/AcDream.Core/Physics/PhysicsResolveCapture.cs)) Static module. When `ACDREAM_CAPTURE_RESOLVE=` is set, every player-side `PhysicsEngine.ResolveWithTransition` call appends one JSON Lines record: ```json { "tick": 0, "timestampMs": 40919993, "input": { ... full inputs ... }, "bodyBefore": { ... full PhysicsBody snapshot ... }, "result": { ... full ResolveResult ... }, "bodyAfter": { ... full PhysicsBody snapshot ... } } ``` Filtered to `IsPlayer` mover flag so NPC / remote DR calls don't pollute. Thread-safe writer with per-record flush. Process-exit hook for clean shutdown. ### Comparison harness ([`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](../../tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs)) Three `LiveCompare_*` tests + one diagnostic dump: | Test | Outcome | Meaning | |---|---|---| | `LiveCompare_Tick0_Spawn` | PASSES | Spawn at Z=92.5333; engine matches live bit-perfect | | `LiveCompare_Tick376_OnRamp` | PASSES | Player on ramp at Z=91.49; ramp walkable polygon hydrates correctly, engine reproduces live | | `LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered` | PASSES (documents the bug) | Live cap at Z=92.74 with cn=(0,0,-1); harness does NOT reproduce because cottage GfxObj isn't registered | | `LiveCompare_FirstCap_DiagnosticDump` | PASSES (probe-only) | Prints cell polygons in world frame + enables every probe — captured stdout shows harness BSP query path | The diagnostic dump test runs the cap replay with `[poly-dump]`, `[push-back]`, `[indoor-bsp]`, `[step-walk]` probes ALL enabled. The captured stdout shows: ``` [cell-dump] 0xA9B40147 resolved-poly-count=37 poly id=0x0018 ... worldVerts=[(140.12,11.50,90.95),...(142.10,11.50,90.95)] ← cellar floor poly id=0x0001 ... worldVerts=[(142.10,11.50,93.80),...(140.50,8.70,93.80)] ← cellar ceiling [cell-dump] 0xA9B40143 resolved-poly-count=14 poly id=0x0004 ... worldVerts=[(136.70,3.90,94.00),(140.50,3.90,94.00),(140.50,8.70,94.00)] ← cottage floor (triangle) ... more cottage floor triangles, all at world Z=94.00 ... [other-cells] primary=0xA9B40147 iter=0xA9B40143 wpos=(141.605,7.097,93.351) result=OK poly=n/a [other-cells] primary=0xA9B40147 iter=0xA9B40146 wpos=(141.605,7.097,93.351) result=OK poly=n/a ``` Both other-cells iterations return OK — the cottage floor polys in 0xA9B40143 don't extend to the sphere's XY (X=141.39 > rightmost-vertex X=140.50). So the harness sees no collision, even though the live engine does. --- ## How we identified the missing object (it's NOT a cell) The second capture pass enabled `ACDREAM_PROBE_RESOLVE=1`, which logs each call's hit details including the entity guid of the blocking object. The cap event prints: ``` [resolve] ent=0x000F4240 in=(141.605,7.304,92.656) tgt=(141.624,6.875,92.656) out=(141.605,7.304,92.656) ok=True groundedIn=True cp=valid hit=yes n=(0.00,0.00,-1.00) obj=0xA9B47900 walkable=True ``` **obj=0xA9B47900** is in the landblock-baked static range (0xA9B47XXX guids belong to landblock 0xA9B4's static objects). This is the cottage BUILDING as a GfxObj registered as a ShadowEntry on the landblock — NOT a cottage cell. The harness's `BuildEngineWithCellarFixtures` loads three CELL fixtures (0xA9B40143, 0xA9B40146, 0xA9B40147) but **does not register any landblock-baked static**. There IS a `RegisterStairRampGfxObj` helper that constructs ONE polygon (the ramp), but it's commented out today. So the missing apparatus is: register the cottage GfxObj as a ShadowEntry with its FULL polygon table — ramp + walls + floor + ceiling. Once registered, the harness's multi-cell BSP iteration's `FindObjCollisions` will query the GfxObj's BSP and find the cottage floor polygon's downward-facing plane just like live. --- ## The cap geometry (math) Live capture analysis confirmed the sphere physics: - Foot sphere center at world Z = foot_z, radius 0.48m - Head sphere center at world Z = foot_z + sphereHeight = foot_z + 1.2m - Head sphere top at Z = foot_z + 1.2 + 0.48 = foot_z + 1.68m Cap point in live capture: foot_z = 92.7390 (from tick 1183). Predicted head sphere position: head center = 93.9390, head top = 94.4190. The cottage floor is at world Z = 94.0 (from cell 0xA9B40143's poly 0x04 worldVerts: `(136.70,3.90,94.00)`, etc.). **Head sphere center at Z=93.94 is BELOW the cottage floor at Z=94.0 by 0.06.** **Head sphere top at Z=94.42 is ABOVE the cottage floor by 0.42.** The head sphere PENETRATES the cottage floor. BSP push-back direction is the negative of the polygon's outward normal (which is +Z facing UP), so push-back direction is −Z (pushes sphere DOWN). That matches the live cn=(0,0,-1). The "exact" cap position: foot_z when head center is at Z=94.0 (just touching). foot_z = 94.0 − 1.2 = 92.80. The observed cap at foot_z=92.74 is ~0.06 below the predicted (push-back includes epsilon and walk-interp adjustments). --- ## User's confirming observation > "I noticed a thing. When I jump in the cellar, I'm getting blocked at > the same height (I think) as I am when running up the stairs." This is the key observation that nailed the diagnosis. **Jumping is pure vertical motion** — no ramp slope, no AdjustOffset projection. If the cap fires on a pure jump, the obstruction must be a horizontal geometric obstacle at the cap height. That immediately rules out every step-up / AdjustOffset hypothesis from the prior 6+6 saga and pinpoints the bug as a head-sphere head-on collision with a cottage-floor polygon facing DOWN. --- ## What's NOT yet known 1. **Why retail doesn't have this cap.** Either: - (a) Retail's cottage GfxObj has a HOLE in the floor above the ramp (cottage floor polygons stop at the ramp opening; our dat-read produces a contiguous floor) - (b) Retail's BSP query treats single-sided polygons correctly (cottage floor's SidesType allows collision from +Z side only, not from −Z side; we treat it as both-sided) - (c) Retail uses portal-aware collision: when the sphere is inside the cellar EnvCell, queries skip polygons that belong to the cottage portal's "other side" Need a retail cdb trace at the ramp-top to disambiguate. 2. **The cottage GfxObj's full polygon list.** We have the ramp polygon (poly 0x0008 in the cottage GfxObj, normal (0,-0.719,0.695)) and we know the floor polygon is at Z=94.0 with normal (0,0,-1) or (0,0,+1). We do NOT have: - the full polygon list of GfxObj 0xA9B47900 - the cottage GfxObj's id, BSP root, or scale/rotation These can all be extracted by enabling `ACDREAM_PROBE_BUILDING=1` for a future capture — the `[resolve-bldg]` probe dumps per-poly geometry when a building shadow entry is hit. 3. **`ACDREAM_PROBE_POLY_DUMP` doesn't fire for the cottage hit.** The [poly-dump] probe is wired into `AdjustSphereToPlane`, but the cottage-floor collision goes through `FindObjCollisions` → `BSPQuery.FindCollisions` on the GfxObj's internal BSP — a different code path. Future probing should use `ACDREAM_PROBE_BUILDING` instead to capture the per-object collision details. --- ## Next-session pickup ### Recommended move: extract cottage GfxObj polygons + register in harness 1. **One more focused live capture** with `ACDREAM_PROBE_BUILDING=1` (the `[resolve-bldg]` probe). The cap event will dump the cottage GfxObj's full BSP root + hit polygon vertices in both object-local and world space, plus the cottage GfxObj id, scale, rotation, and world origin. 2. **Add `RegisterCottageGfxObj(engine, cache)` helper** in the harness (rename or extend `RegisterStairRampGfxObj`). Construct a synthetic GfxObj with the full polygon table extracted from the capture. The BSP needs all polygons (ramp + cottage floor + walls); a one-leaf wrapper suffices for the comparison-test scope. 3. **Uncomment the registration call** in `BuildEngineWithCellarFixtures`. Re-run `LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered` — it should now FAIL because the harness DOES reproduce the cap. Flip the test body to `AssertCallMatchesCapture(engine, captured)` to enforce the round-trip. 4. **At this point the harness is FAITHFUL to live.** Now the question becomes "how does retail differ?" — which is a retail cdb investigation, not an apparatus investigation. ### Alternative move: skip ahead to the retail cdb question If the user is impatient with apparatus polish, skip step 3 and go directly to a retail trace of the cellar-up scenario. Attach cdb to a running retail acclient, set breakpoints on `BSPTREE::find_collisions` and `CGfxObj::shadow_find_obj_collisions`, walk up the cottage ramp, log every BSP query against the cottage GfxObj. Compare which polygons retail finds vs which polygons our acdream engine finds. The retail trace is the ultimate oracle — but it requires reproducible retail-server state (the user has a working `+Acdream` on local ACE, but the retail client also needs to connect there, which works per the CLAUDE.md "Retail debugger toolchain" section). --- ## Apparatus that exists to use | Tool | Location | Status | |---|---|---| | `PhysicsResolveCapture` | `src/AcDream.Core/Physics/` | Production-ready; env-var gated; off by default | | `LiveCompare_*` tests | `tests/.../CellarUpTrajectoryReplayTests.cs` | 4 tests; 1 documents the bug, 3 are matches | | `live-capture.jsonl` fixture | `tests/.../Fixtures/issue98/` | 3 representative records (spawn, on-ramp, first-cap) | | `launch-a6-issue98-capture.ps1` | worktree root | Capture-enabled launch (no diagnostic probes) | | `launch-a6-issue98-polydump.ps1` | worktree root | Capture + poly-dump + push-back + dump-cells launch | | 16 cell-dump fixtures | `tests/.../Fixtures/issue98/0xA9B4014X.json` | All cells in 0xA9B4014X range from second capture | | 41K-record live capture | `a6-issue98-resolve-capture.jsonl` (gitignored size) | First capture — full session of cellar movement | | 70K-record live capture w/ probes | `a6-issue98-resolve-capture-2.jsonl` | Second capture — included poly-dump events | | `a6-issue98-polydump-launch.log` | worktree root | 56K+ line log with [resolve], [poly-dump], [other-cells], [indoor-bsp] events | --- ## Pickup prompt for next session ``` A6.P3 #98 comparison harness — session paused 2026-05-23 evening. Read FIRST: 1. docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md (this doc — TL;DR, what was shipped, the root cause finding) 2. tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs (especially the LiveCompare_* tests + LiveCompare_FirstCap_DiagnosticDump) 3. CLAUDE.md "Current A6 phase" block (look for the 2026-05-23 evening apparatus + finding paragraph) State both altitudes: Currently working toward: M1.5 — Indoor world feels right Current phase: A6.P3 — comparison harness shipped + root cause identified. Cottage GfxObj 0xA9B47900 missing from harness; needs full polygon list extracted from a focused capture. The doc has TWO concrete options for what to do next: (A) Extract cottage GfxObj polygons via focused ACDREAM_PROBE_BUILDING capture, register as ShadowEntry in harness, flip LiveCompare_FirstCap_* test to assertion form. ~2 hours. Apparatus convergence. RECOMMENDED. (B) Skip ahead to retail cdb trace of cottage ramp-top BSP queries to answer "what does retail actually do differently?". Larger scope; more direct fidelity question. Pick A or B. If A: the doc has a step-by-step plan in the "Recommended next-session move" section. CLAUDE.md rules apply throughout. NO speculative fixes — the saga already converted from speculation to evidence-driven; keep it that way. Test baseline: 1178 + 8 pre-existing failures (serial run). Maintain throughout. The previously-failing LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered test is now in documents-the-bug form (PASSES while bug exists; FAILS when fix lands) — flip it when the cottage GfxObj is registered. ```