acdream/docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
Erik 7729bdcf98 docs: A6.P3 #98 — record apparatus convergence + residual X-motion
The findings doc gets an evening-v2 follow-on documenting:
  - GfxObj dump infrastructure shipped (cc3afbc)
  - Harness reproduces cap-event collision normal (97fec19)
  - Residual +0.0266m X-motion divergence — the new investigation target
  - Pre-existing test suite flakiness (out of scope, tracked separately)

CLAUDE.md's "Current A6 phase" block points at the residual divergence
as the next concrete move with the test that gives <1s feedback per fix
attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:47:17 +02:00

378 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
**Updated 2026-05-23 evening v2: apparatus convergence shipped.** The
harness now reproduces the live cottage-floor cap event bit-perfect on
the collision normal. The residual divergence is a single +X-motion
edge-slide gap; everything else round-trips. The session below covers
both arcs (root-cause identification THEN convergence).
- **Evidence-driven apparatus shipped.** `PhysicsResolveCapture` writes one
JSON Lines record per player ResolveWithTransition call when
`ACDREAM_CAPTURE_RESOLVE=<path>` 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
FAILED with a clear divergence — the right divergence.
- **Root cause identified: the cottage GfxObj was 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.
- **Apparatus convergence (v2 update).** With the cottage GfxObj
`0x01000A2B` extracted via the new `ACDREAM_DUMP_GFXOBJS` infrastructure
and registered as a ShadowEntry in `BuildEngineWithCellarFixtures`, the
harness now reproduces the live `cn=(0,0,-1)` cap exactly. The
full per-field round-trip reveals one residual: live preserves
+0.0266 m of +X motion through the cap; harness blocks all motion.
That's the next investigation target — see the "Residual divergence"
section below.
- **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 sharpened to "how does live's post-cap edge-slide
preserve the +X component that the harness drops?"
---
## 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=<path>` 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
### What shipped 2026-05-23 evening v2 (post-prior-section)
Three commits land apparatus convergence on the cap event:
| Commit | What |
|---|---|
| `cc3afbc` | **GfxObj dump infrastructure.** Mirrors `ACDREAM_DUMP_CELLS`: new env var `ACDREAM_DUMP_GFXOBJS` triggers `PhysicsDataCache.CacheGfxObj` to write the full resolved polygon table as JSON, suffix `.gfxobj.json` so dumps don't collide with cell dumps in the same dir. New `GfxObjDump` DTO + `GfxObjDumpSerializer` parallel to `CellDump`; round-trip tests cover Capture / Write / Read / Hydrate; the Hydrate path constructs a synthetic single-leaf BSP for query coverage. |
| `97fec19` | **Harness reproduces the cottage-floor cap event.** `BuildEngineWithCellarFixtures` now registers a stub landblock 0xA9B40000 (TerrainSurface at z=-1000) so `TryGetLandblockContext` succeeds at the cellar XY, plus a new `RegisterCottageGfxObj` helper that loads the dumped cottage GfxObj fixture, hydrates it with synthetic BSP, and registers as a ShadowEntry at world (130.5, 11.5, 94.0) with 180° Z rotation — matching production's `GameWindow.cs:5893` registration shape for landblock-baked statics. The cottage fixture (74 polys, 6 downward-facing floor triangles, BSP radius 13.989 m) lives at `tests/.../Fixtures/issue98/0x01000A2B.gfxobj.json`; capture launch script is `launch-a6-issue98-cottage-gfxobj-dump.ps1`. |
Test outcome at apparatus convergence:
| Test | Outcome | Meaning |
|---|---|---|
| `LiveCompare_Tick0_Spawn` | PASS | Spawn round-trip preserved by the new landblock + cottage state |
| `LiveCompare_Tick376_OnRamp` | PASS | On-ramp round-trip preserved |
| `LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal` | PASS (NEW) | Harness reproduces the live cn=(0,0,-1) cap-event normal exactly |
| `LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation` | PASS (documents-the-bug) | Captures the ONE remaining post-cap divergence: live preserves +0.0266 m of +X motion through the cap (edge-slide along the cottage floor in XY); harness blocks ALL motion. Y and Z agree. |
### The residual divergence (next investigation target)
After registering the cottage GfxObj:
```
Live: cn=(0,0,-1), position=(141.3865, 7.2243, 92.7390) ← +X motion preserved
Harness: cn=(0,0,-1), position=(141.3599, 7.2243, 92.7390) ← X stuck at input
Input: currentPos=(141.3599, 7.2243, 92.7390)
targetPos =(141.3865, 6.8221, 92.7390)
requestedDelta=(+0.0266, -0.4022, 0)
```
The cap-event collision normal matches bit-perfect. Position diverges
in X only. Working hypothesis: live's response to a `cn=(0,0,-1)`
head-bump treats it as a Z-only constraint and edge-slides the
remaining XY component along the cottage floor; harness's BSP path is
rejecting the entire move vector instead of computing a slid offset.
That hypothesis is the next-session investigation target — work the
slide path in `Transition.transitional_insert` / `AdjustOffset` against
the production cap-event call. The new
`LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation`
test PASSES today (asserting the current residual) and FAILS when the
divergence closes — that's the signal to flip it into
`AssertCallMatchesCapture` form.
### Alternative pickup move: retail cdb trace at the cottage ramp-top
If apparatus polish is enough and the user wants to widen the question
to "how does retail differ?", attach cdb to a running retail acclient
(see CLAUDE.md "Retail debugger toolchain"), set breakpoints on
`BSPTREE::find_collisions` and `CGfxObj::shadow_find_obj_collisions`,
walk up the cottage ramp, and log every BSP query against the cottage
GfxObj. Compare which polygons retail finds vs which polygons our
acdream engine finds. Retail's trace is the ultimate oracle for the
"how does retail differ?" question — but the apparatus-side X residual
investigation is the more focused, faster-feedback next step.
### Pre-existing test flakiness (out of scope but documented)
While verifying the cottage helper, the full `dotnet test` serial run
produced 819 failures across 1192 tests depending on order — the
suite has static-state leakage between test classes (likely from
`PhysicsResolveCapture.CapturePath`, `PhysicsDiagnostics.Probe*Enabled`,
and similar global mutators). The flakiness is **independent of A6.P3**:
stashing the cottage helper out and rerunning produces the same flaky
range. All 21 issue-#98-relevant tests (12 harness + 4
`GfxObjDumpRoundTripTests` + 1 new `PhysicsDiagnosticsTests` + 4
`CellDumpRoundTripTests`) pass deterministically in isolation.
---
## 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 — apparatus convergence landed, residual X-motion divergence
is next.
Read FIRST:
1. docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
(this doc — particularly the "What shipped 2026-05-23 evening v2"
and "The residual divergence" sections)
2. tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
(especially the two LiveCompare_FirstCap_* tests and the
RegisterCottageGfxObj helper)
3. CLAUDE.md "Current A6 phase" block
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P3 — apparatus convergence shipped. Harness now
reproduces the live cottage-floor cap event (cn=(0,0,-1) round-trips
bit-perfect). Residual: +0.0266 m of +X motion lost in the harness's
post-cap slide where live preserves it.
Two concrete next moves:
(A) Investigate the +X edge-slide divergence in the harness. The
LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation
test currently passes asserting the divergence; flipping it should
drive the investigation. Likely target: Transition.transitional_insert
/ AdjustOffset's handling of a cn=(0,0,-1) head-bump — live treats
it as Z-only constraint and edge-slides the remaining XY motion;
harness blocks all motion. Decomp anchor: acclient_2013_pseudo_c.txt
in the find_obj_collisions → adjust_sphere_to_plane chain. ~2 hours
estimate.
(B) Attach cdb to retail at the cottage ramp-top, trace the BSP queries,
compare polygon-by-polygon what retail finds vs what acdream finds.
Authoritative for the "how does retail differ?" question but
larger scope (~half day setup + capture).
(A) is recommended — the harness now isolates this divergence to a
specific known XY slide path; the test gives <1s feedback per fix
attempt. (B) becomes valuable if (A) hypothesis chase stalls.
CLAUDE.md rules apply throughout. NO speculative fixes — the saga
already converted from speculation to evidence-driven; keep it that
way.
Out-of-scope but observed: pre-existing test suite has 819 failures
across runs of the same code due to static-state leakage between test
classes (PhysicsResolveCapture, PhysicsDiagnostics statics). Targeted
issue-#98 tests pass deterministically in isolation. Don't touch the
flakiness this session; it's a separate investigation.
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.
```