Today's evening session ran from "harness still doesn't reproduce the cap" → "harness reproduces it" → "wait, the cap is only a symptom, the real cause is upstream Z drift from the contact plane never refreshing." The breakthrough question, from the user: "we know how retail OPENs it from above, how hard can it be to know how to open it from below?" — which reframed the investigation away from cap-event mechanics (where six prior attempts looked) and toward "what about our STATE is wrong when the player is in the cellar but not on the ramp?" The math: player at cap is 10 m away from the cellar ramp in cell-local X, but body.ContactPlane is still the ramp's slope plane. AdjustOffset projects forward motion along that stale slope every tick, lifting Z by +0.201 m per tick. After enough ticks of horizontal walking, the head sphere reaches Z=94 and bumps the cottage floor. If the contact plane refreshed to the flat cellar floor when the player walked off the ramp, the drift would be zero, the cap would never be reachable. Next session's task (per the pickup prompt at the bottom of the findings doc): (1) verify the hypothesis chronologically against the live capture, (2) find the walkable-refresh gap in Transition.FindEnvCollisions / SpherePath.SetWalkable, (3) cross-ref retail's CObjCell::find_env_collisions for the per-tick contact-plane refresh logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
536 lines
27 KiB
Markdown
536 lines
27 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
|
||
|
||
**Updated 2026-05-23 evening v3: NEW root-cause hypothesis identified —
|
||
STALE RAMP CONTACT PLANE causes per-tick Z drift, which is what makes
|
||
the cottage-floor cap reachable in the first place.**
|
||
|
||
- Player position at cap: world (141.5, 7.2, 92.7). The cellar ramp's
|
||
actual world XY is X=[129.7, 131.3] — the player is **10 meters away
|
||
from the ramp** in cell-local space.
|
||
- Body's contact plane: ramp's plane (n=(0, 0.719, 0.695), d=-69.5035).
|
||
Stale; should be the flat cellar floor (n=(0,0,1)).
|
||
- AdjustOffset projects forward motion along that stale ramp plane.
|
||
Mathematically: requested delta (+0.0266, -0.4022, 0) → projected
|
||
(+0.0266, -0.1943, +0.2010). **+0.2010 m of Z lift per tick.**
|
||
- After enough horizontal-walking ticks, the head sphere rises to
|
||
Z=94 and hits the cottage floor's downward-facing back-face polygon.
|
||
Cap fires.
|
||
- The cap is a SYMPTOM. The root cause is the contact plane not
|
||
refreshing when the player walks off the ramp onto the flat cellar
|
||
floor. Retail must re-find the walkable plane each tick; we're
|
||
keeping the stale ramp seed.
|
||
|
||
**This explains why six prior fix attempts missed.** Step-up,
|
||
AdjustOffset projection, SidesType, edge-slide, +X residual — all
|
||
were investigating the cap event mechanics, not the upstream Z drift
|
||
that made the cap reachable. The harness convergence (Section "What
|
||
shipped 2026-05-23 evening v2") is still valuable as the deterministic
|
||
reproduction infrastructure; the new hypothesis is the **next** thing
|
||
to verify against that infrastructure.
|
||
|
||
(Sections below preserve the evening-v2 arc for context: apparatus +
|
||
cap-event reproduction.)
|
||
|
||
- **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 8–19 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 |
|
||
|
||
---
|
||
|
||
## The stale-contact-plane finding — full evidence (2026-05-23 evening v3)
|
||
|
||
### How the question led to the answer
|
||
|
||
User asked: "We know how retail OPENs it from above, how hard can it
|
||
be to know how to open it from below?" — the implicit question being
|
||
"if walking on the cottage floor from above works fine, why doesn't
|
||
walking up from below?"
|
||
|
||
That reframed the investigation. The cottage floor is the SAME
|
||
polygon set whether viewed from above (walking on it) or below
|
||
(head-bumping it from the cellar). Retail handles both. If our cap
|
||
fires from below, what's different about our state?
|
||
|
||
Tracing the harness's `LiveCompare_FirstCap_DiagnosticDump` output
|
||
revealed:
|
||
|
||
1. **The contact plane the engine started with**: ramp's plane
|
||
`n=(0, 0.7190, 0.6950), d=-69.5035`. From the live capture's
|
||
`bodyBefore.contactPlane`.
|
||
|
||
2. **Cellar ramp's actual world position**: vertices computed from
|
||
the cellar cell's fixture put the ramp at world
|
||
X∈[129.7, 131.3], Y∈[10.19, 13.09], Z∈[92.5, 95.5]. The ramp is
|
||
in the +Y corner of the cellar, ~1.6 m wide.
|
||
|
||
3. **Player position at cap**: world (141.5, 7.22, 92.74). 10+ m
|
||
away from the ramp in X.
|
||
|
||
4. **The +Z drift math**: `AdjustOffset` projects the requested
|
||
motion onto the plane perpendicular to the contact-plane normal:
|
||
- requested = (+0.0266, -0.4022, 0)
|
||
- dot(requested, ramp normal) = 0·0.0266 + 0.719·(-0.4022) +
|
||
0.695·0 = -0.2892
|
||
- projected = requested - (-0.2892)·rampNormal =
|
||
(+0.0266, -0.1943, +0.2010)
|
||
- **+0.2010 m of Z gain per tick**, applied because the contact
|
||
plane the engine believes the player is on is the slope.
|
||
|
||
5. **The cap math**: foot Z at cap = 92.74. Head sphere center at
|
||
foot Z + sphereHeight 1.2 = 93.94. Head sphere top at
|
||
foot Z + 1.68 = 94.42. **Cottage floor at world Z=94.00.** Head
|
||
sphere top exceeds cottage floor by 0.42 m → cap fires from
|
||
below.
|
||
|
||
If the contact plane were the flat cellar floor (n=(0,0,1) at
|
||
Z=90.95) instead of the ramp, AdjustOffset's projection would
|
||
produce zero Z gain (requested motion has no Z component, projection
|
||
onto flat-floor plane preserves XY). No drift, no cap.
|
||
|
||
### Why this fits the user-facing bug
|
||
|
||
- "Stuck climbing cellar" — the player walks forward, accumulates Z,
|
||
bumps cottage floor, can't progress. Matches what the user sees.
|
||
- "Pure jump in cellar caps at same Z" — jumping doesn't refresh the
|
||
contact plane either. Drift continues. Matches.
|
||
- "Six prior fix attempts failed" — all attempted to fix the CAP
|
||
mechanics (step-up, slope projection at the cap, edge-slide). None
|
||
questioned why the contact plane was the ramp at all.
|
||
|
||
### What still needs verification (next session's task)
|
||
|
||
1. **Chronological evidence**: walk the live capture from the start of
|
||
the cellar session. When did the player last stand on the actual
|
||
ramp? Does `bodyBefore.contactPlane` persist as the ramp's plane
|
||
across many ticks of horizontal walking? Quantify the cumulative
|
||
Z drift.
|
||
|
||
2. **The walkable-refresh gap**: where in
|
||
`Transition.FindEnvCollisions` / `SpherePath.SetWalkable` /
|
||
related is the contact plane supposed to be refreshed when the
|
||
sphere is over a different walkable polygon? Retail's
|
||
`CObjCell::find_env_collisions` is the decomp anchor — find the
|
||
path that detects a NEW walkable and overwrites the contact
|
||
plane, and find where our engine skips that.
|
||
|
||
3. **Retail cdb cross-check** (optional, definitive): attach cdb to a
|
||
running retail acclient, walk to a cottage cellar, log the
|
||
contact plane each tick. If retail's contact plane refreshes
|
||
to (0,0,1) when the player walks off the ramp, hypothesis
|
||
confirmed.
|
||
|
||
---
|
||
|
||
## Pickup prompt for next session
|
||
|
||
```
|
||
A6.P3 #98 — apparatus convergence landed, NEW root-cause hypothesis
|
||
(stale ramp contact plane) needs verification.
|
||
|
||
Read FIRST (in order, ~15 min):
|
||
1. docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md
|
||
— start with TL;DR (evening v3 update at top), then the section
|
||
"The stale-contact-plane finding — full evidence" near the bottom.
|
||
Skip the middle sections (evening v1 + v2 arcs) unless context is
|
||
needed.
|
||
2. CLAUDE.md "Current A6 phase" block — look for the "Evening v3
|
||
finding" paragraph.
|
||
3. tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
|
||
— the RegisterCottageGfxObj helper + 2 LiveCompare_FirstCap_*
|
||
tests are what you'll iterate against.
|
||
|
||
State both altitudes (one sentence each):
|
||
Currently working toward: M1.5 — Indoor world feels right
|
||
Current phase: A6.P3 — apparatus convergence shipped (cap event
|
||
reproduces bit-perfect). New root-cause hypothesis: stale ramp
|
||
contact plane causes per-tick Z drift that makes the cap reachable.
|
||
Needs verification.
|
||
|
||
What was shipped today (3 commits — DO NOT REDO):
|
||
- cc3afbc: GfxObj dump infrastructure (ACDREAM_DUMP_GFXOBJS)
|
||
- 97fec19: Harness reproduces cottage-floor cap (RegisterCottageGfxObj)
|
||
- 7729bdc + (this commit): findings doc + CLAUDE.md updates
|
||
|
||
The hypothesis with full math:
|
||
- Body's contact plane = ramp's plane (n=(0,0.719,0.695), d=-69.5035)
|
||
- Player position at cap = world (141.5, 7.22, 92.74)
|
||
- Cellar ramp's actual world XY = X∈[129.7, 131.3] — 10m from player
|
||
- AdjustOffset projects requested move along contact-plane perpendicular
|
||
- Per-tick Z gain ≈ 0.201m from slope projection on STALE ramp plane
|
||
- Accumulates over ticks → head sphere reaches Z=94 → bumps cottage
|
||
floor → cap fires
|
||
- If contact plane refreshed to flat cellar floor (n=(0,0,1)) when
|
||
player walks off ramp, no Z drift, no cap
|
||
|
||
Concrete next moves (in order):
|
||
|
||
(1) **Verify the hypothesis chronologically.** Walk
|
||
a6-issue98-resolve-capture-2.jsonl (or the cottage capture
|
||
fixture's full file) from the start. Find when the player last
|
||
stood on the actual ramp (within world X∈[129.7, 131.3], Y∈[10.19,
|
||
13.09]). Quantify: how many ticks does the body's contact plane
|
||
persist as the ramp's plane while the player walks horizontally
|
||
away? Compute the cumulative Z drift. Should match observed Z=92.74
|
||
at cap if the hypothesis holds. (Probably 30 min PowerShell jq.)
|
||
|
||
(2) **Locate the walkable-refresh code path.** In
|
||
src/AcDream.Core/Physics/TransitionTypes.cs, search for where
|
||
Transition.FindEnvCollisions or SpherePath.SetWalkable is supposed
|
||
to detect a new walkable polygon under the sphere and overwrite
|
||
the contact plane. The fix likely lives at the call site that
|
||
EITHER fails to fire OR fires but doesn't replace the existing
|
||
contact plane.
|
||
|
||
(3) **Cross-ref retail decomp.** acclient_2013_pseudo_c.txt's
|
||
CObjCell::find_env_collisions + the walkable-detection chain.
|
||
Find the path where retail unconditionally replaces
|
||
contact_plane when a new walkable is found. Quote the line
|
||
numbers in the fix commit.
|
||
|
||
(4) **Implement the fix + verify against harness.** The harness's
|
||
LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal test
|
||
currently PASSES asserting the cap reproduces. After the fix,
|
||
if the contact plane refreshes correctly, the cap should NOT fire
|
||
(no Z drift to make it reachable). The test should start FAILING
|
||
— that's the signal the fix works.
|
||
|
||
(5) **Visual verification (user-side).** Launch acdream live, walk
|
||
into a Holtburg cottage, down to the cellar, then back up. The
|
||
user-facing bug should resolve if the hypothesis is correct.
|
||
|
||
Decomp grep targets:
|
||
- CObjCell::find_env_collisions
|
||
- CPhysicsObj::find_object_collisions
|
||
- CTransition::find_walkable
|
||
- CSpherePath::set_walkable / walkable_hits_sphere
|
||
- OBJECTINFO::object → contact_plane writes
|
||
|
||
CLAUDE.md rules apply throughout:
|
||
- NO speculative fixes — the saga's converted to evidence-driven.
|
||
Verify hypothesis with chronological capture BEFORE coding.
|
||
- Visual verification belongs to the user.
|
||
- If the chronological verification (step 1) shows the contact
|
||
plane is NOT actually stale across many ticks, the hypothesis is
|
||
wrong — pivot to retail cdb trace (definitive oracle).
|
||
|
||
Out-of-scope but observed: pre-existing test suite has 8–19 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: harness's 12 CellarUpTrajectoryReplayTests + 4
|
||
GfxObjDumpRoundTripTests + 1 new PhysicsDiagnosticsTests + 4
|
||
CellDumpRoundTripTests all pass in isolation. Maintain.
|
||
|
||
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.
|
||
```
|