Session-end documentation for the 2026-05-23 evening session in
which:
1. The PhysicsResolveCapture apparatus shipped (committed earlier
in fb5fba6).
2. A live capture (41K records) drove the first LiveCompare_* tests
in CellarUpTrajectoryReplayTests, two of which PASS bit-perfect.
3. The failing third test pinpointed the cap-event divergence.
4. A second capture (70K records + 16 cell dumps + per-poly probes)
identified the cottage GfxObj 0xA9B47900 as the blocker — a
landblock-baked static building whose floor polygons live in the
GfxObj's BSP, NOT in any cottage cell.
The findings doc has:
- TL;DR + chronological commits
- Apparatus inventory (PhysicsResolveCapture, comparison tests,
fixtures, launch scripts)
- The math: head sphere top at Z=foot+1.68 reaches the cottage floor
at Z=94.0 when foot Z=92.74, matching the observed cap.
- User's confirming observation (cap fires on pure-vertical jump too,
ruling out every step-up / AdjustOffset hypothesis)
- What's NOT yet known (why retail doesn't have this cap; full
cottage GfxObj polygon list)
- Next-session pickup with two ranked options
Adds:
- docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
- launch-a6-issue98-capture.ps1 (capture-only launch)
- launch-a6-issue98-polydump.ps1 (capture + diagnostic probes + 16-cell dump)
- 13 new cell-dump fixtures (0xA9B40140-0xA9B40142, 0xA9B40144,
0xA9B40145, 0xA9B40148-0xA9B4014F) at 272 KB total. The harness now
has the full 0xA9B4014X neighborhood available for any future
comparison test that needs adjacent cell geometry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
15 KiB
Markdown
321 lines
15 KiB
Markdown
# 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=<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
|
||
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=<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
|
||
|
||
### 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.
|
||
```
|