Knowledge-preservation pass after the issue #98 cellar-up fix shipped (`b3ce505`). Closes the saga's documentation loop and plans the next phase. Changes: - docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md Appended "Resolution 2026-05-24" section: v3 hypothesis falsified, actual mechanism (head-bump cottage GfxObj floor poly from below) confirmed,b3ce505fix shipped, known door regression flagged. Memory artifacts cross-referenced. - docs/ISSUES.md #98 moved to DONE with full resolution writeup + decomp anchors. #99 filed: door regression at building thresholds (caused by b3ce505's indoor-primary gate). Closes via A6.P4. #100 filed: transparent rectangular patches around houses (terrain rendering). Bisect found commit35b37dfintroduced the hiddenTerrainCells mechanism that collapses 24m outdoor cells when buildings sit in them; cottage building only fills part of its cell so the rest of the 24m cell shows the sky-bleeding gap. Three fix-path options documented. - docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md Full A6.P4 design doc. Three-slice plan: (1) query-side portal expansion to close #99 while preserving #98 fix, (2) port retail's BuildShadowCellSet at registration time so per-cell semantics match `CObjCell::find_cell_list`, (3) removeb3ce505stopgap entirely. Decomp anchors, file-by-file plan, risk inventory, open questions. Memory entries written separately (out-of-tree at ~/.claude/projects/.../memory/): - feedback_retail_per_cell_shadow_list.md The architectural lesson: retail uses per-cell shadow_object_list with portal-aware registration; our landblock-wide spatial registry diverges at indoor/outdoor seams. - feedback_apparatus_for_physics_bugs.md The apparatus-first pattern that cracked the saga: live capture + fixture dump + replay harness. Template for future physics bugs. Quote rule: "when a physics bug is resisting and you catch yourself about to ship 'fix attempt N+1 with no new evidence,' STOP. Build the apparatus first." - MEMORY.md index updated with both new entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
649 lines
32 KiB
Markdown
649 lines
32 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.
|
||
```
|
||
|
||
---
|
||
|
||
## Resolution 2026-05-24
|
||
|
||
### What was wrong with the evening-v3 hypothesis
|
||
|
||
The v3 "stale ramp contact plane" hypothesis (top of this doc) was
|
||
**FALSIFIED** by chronological walk of `a6-issue98-resolve-capture-2.jsonl`:
|
||
|
||
- Player position at the first cap event (tick 55101, line 55102 of the
|
||
JSONL): world `(141.605, 7.304, 92.656)`
|
||
- `bodyBefore.walkableVertices`: the ramp polygon at world
|
||
X∈[140.5, 142.1], Y∈[5.80, 8.70], Z∈[90.99, 93.99]
|
||
- Player XY is **inside** the ramp polygon's footprint
|
||
- `bodyBefore.contactPlane.normal` = (0, 0.7189884, 0.69502217) — the
|
||
ramp's plane
|
||
|
||
The v3 doc claimed "ramp at world X∈[129.7, 131.3], 10m away from
|
||
player." That geometry was computed from a wrong source (not the actual
|
||
ramp polygon). The live capture's `walkableVertices` are the ground
|
||
truth and show the player IS on the ramp at the cap event. The contact
|
||
plane is the ramp's plane because the player is on the ramp — correct,
|
||
not stale.
|
||
|
||
Tick 55020 (line 55021) shows the contact plane refreshing in real time
|
||
as the player crossed onto the ramp: `bodyBefore` had the previous
|
||
polygon's plane, `bodyAfter` had the ramp's plane. The walkable-refresh
|
||
chain works. No drift mechanism exists in the way v3 described.
|
||
|
||
### What the actual mechanism was
|
||
|
||
The evening-v2 finding was correct: head-sphere bumps the cottage
|
||
GfxObj's downward-facing floor poly (poly 0 in the GfxObj fixture, a
|
||
triangle covering world X∈[136.3, 142.5], Y∈[3.5, 19.5], Z=94) from
|
||
below. Player at (141.605, 7.304) is inside that triangle. Head sphere
|
||
top at Z=foot+1.68=94.336 penetrates the cottage floor at Z=94 by
|
||
0.336m → cn=(0,0,-1) push-back → stuck.
|
||
|
||
Why retail doesn't have this cap: decomp grep of
|
||
`CObjCell::find_obj_collisions` (line 308916) shows retail iterates
|
||
`this->shadow_object_list` — a **per-cell list**. `CObjCell::find_cell_list`
|
||
(line 308742) branches indoor/outdoor at registration time: indoor adds
|
||
only the indoor cell + portal-visible neighbors; outdoor adds all
|
||
overlapping outdoor cells via `add_all_outside_cells`. So a landblock-
|
||
baked static like the cottage gets added to outdoor cells'
|
||
shadow_object_list only — never to indoor EnvCells like the cellar.
|
||
`CEnvCell::find_collisions` therefore never tests the sphere against
|
||
the cottage when sphere is inside the cellar.
|
||
|
||
`sides_type` (the polygon flag the v2 finding option (b) speculated
|
||
about) does NOT affect retail's BSP collision code — it only appears in
|
||
rendering/mesh-batch code. The collision-path divergence is purely
|
||
architectural: per-cell list vs spatial-radius registry.
|
||
|
||
### What shipped (commit b3ce505)
|
||
|
||
Smallest behavioral patch matching retail's effect at the query level:
|
||
|
||
- `ShadowObjectRegistry.GetNearbyObjects` gained an optional
|
||
`primaryCellId` parameter. When indoor (≥ 0x0100), the outdoor radial
|
||
sweep is skipped — only indoor-scoped shadows from `indoorCellIds` are
|
||
returned.
|
||
- `Transition.FindObjCollisions` passes `sp.CheckCellId`.
|
||
- Harness `LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal`
|
||
flipped to `LiveCompare_FirstCap_FixClosesCottageFloorCap` — asserts
|
||
the downward-facing cottage-floor cap does NOT fire after the fix.
|
||
- Residual-X-motion test deleted — it documented post-cap edge-slide,
|
||
irrelevant once the cap is gone.
|
||
|
||
Verified: 11/11 cellar harness tests pass. 55 directly-affected physics
|
||
tests pass. Pre-existing static-state leakage failures (8–19 across
|
||
serial runs) unchanged. Full `dotnet build` clean.
|
||
|
||
Visual verification: user confirmed "Finally I can go up!" in the
|
||
Holtburg cottage cellar.
|
||
|
||
### Known regression caused by b3ce505 + next phase
|
||
|
||
Doorway edge case (flagged in the commit message): doors are server-
|
||
spawned entities with their own cylinder collision, registered via
|
||
`UpdatePosition` to whichever cell their position resolves to. Doors at
|
||
building thresholds typically resolve to outdoor cells. With the
|
||
indoor-primary radial-sweep gate, a sphere inside an indoor doorway-
|
||
adjacent cell doesn't see the outdoor door → can walk through.
|
||
|
||
User reported this: "I can also run through doors."
|
||
|
||
This regression is the direct consequence of NOT doing retail's full
|
||
portal-aware shadow propagation at registration time. Retail's
|
||
`find_cell_list` indoor branch recurses through `VisibleCellIds` and
|
||
adds the object to all portal-visible cells. Our `Register` doesn't do
|
||
this; the b3ce505 stopgap covers cottage-cellar but not doorways.
|
||
|
||
**Next phase: A6.P4 — port retail's per-cell shadow_object_list
|
||
architecture in full.** Design spec at
|
||
`docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md`
|
||
(this session). Approach: refactor `ShadowObjectRegistry.Register` to
|
||
compute the cell set via the retail-faithful indoor/outdoor branch +
|
||
portal-visible recursion (using `CellPhysics.VisibleCellIds`). Eliminate
|
||
the cellScope=0 spatial approximation. `GetNearbyObjects` becomes pure
|
||
per-cell list iteration. Removes the b3ce505 stopgap. Closes the door
|
||
regression as a side effect.
|
||
|
||
Also-likely-closed by A6.P4: #97 (phantom collisions on 2nd floor),
|
||
indoor sling-out (Finding 3 family), other indoor/outdoor seam bugs.
|
||
|
||
### Memory updates (this resolution)
|
||
|
||
- `feedback_retail_per_cell_shadow_list.md` — the architectural lesson
|
||
- `feedback_apparatus_for_physics_bugs.md` — the apparatus pattern that
|
||
finally cracked this saga (template for future physics bugs)
|
||
|