acdream/docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md
Erik 28c282a563 docs(phys): A6.P3 #98 Step 5 — replay vs retail divergence comparison
Closes the apparatus loop. Side-by-sides acdream's deterministic replay
(commit 856aa78) against retail's cdb capture taken via Step 4's
runner. The divergence target is named; the fix plan is the next plan.

Retail data (cellar_up_capture_1):
- 35,219 BP hits over ~5 seconds of motion
- BPE (set_contact_plane): 161 writes, ALL to one of two flat planes
  (n=(0,0,1) d=-93.9998 = cottage floor @ Z=94, OR d=-90.95 = cellar
  floor @ Z=90.95). Retail NEVER sets ContactPlane to the cellar ramp.
- BPC (find_crossed_edge): 1 hit in 35K. Retail barely uses this
  predicate during cellar-up.
- BPA (find_walkable) sphere position at each cottage-floor
  acceptance: sphere LOCAL Z = +0.48 to +0.63 (resting on top of the
  floor plane). Sphere world Z ≈ 94.48.

acdream replay (Issue98CellarUpReplayTests):
- At the failing-frame sphere (world (141.7, 8.4, 92.0)), the cottage
  cell 0xA9B40143's poly 0x0004 reports insideEdges=false AND
  overlapsSphere=false. Sphere local Z = -0.69 (below the cottage
  floor plane). 0xA9B40146 has no walkable candidate at all. Step-up
  has nothing to step onto → stuck.

Sphere world Z delta: 2.47m. Retail's sphere is 2.5m higher than ours
at the decision point. The fix targets, in priority order:

1. (HIGHEST CONFIDENCE) Step-up + ramp climb doesn't gain enough Z per
   tick. Retail climbs the ramp GRADUALLY across thousands of ticks;
   ours oscillates at world Z ≈ 92 without altitude gain. Look at
   Transition.AdjustOffset (slope projection) and Transition.DoStepUp
   (does it reset WalkInterp like retail's step_sphere_up?).

2. Cottage-cell candidacy uses wrong sphere reference. Check what
   sphere CheckOtherCells passes to BSPQuery.FindCollisions — is it
   the step-lifted sphere or the pre-step sphere?

3. (SECONDARY) find_crossed_edge over-use. Our walkable test calls
   FindCrossedEdge heavily; retail barely uses it. Possibly a
   code-shape mismatch in step-up vs walkable-acceptance flow.

4. (LOW CONFIDENCE) Ramp polygon normal divergence. Verify via test
   after any fix.

The apparatus that gets us here:
- tests/AcDream.Core.Tests/Fixtures/issue98/*.json (real cell geometry)
- Issue98CellarUpReplayTests (7 tests, <1ms each, deterministic bug
  reproduction)
- tools/cdb/issue98-runner.ps1 (reusable for any future capture)
- docs/research/2026-05-23-a6-captures/cellar_up_capture_1/ (this
  capture, checked in for future analyses)

Next plan: pick Target 1 or 2 from the comparison doc and write the
fix plan against it. The replay harness is the test loop; a fix that
doesn't change the failing assertions in Issue98CellarUpReplayTests is
not the fix.
2026-05-23 15:57:12 +02:00

15 KiB

A6.P3 issue #98 — acdream replay vs retail cdb comparison

Date: 2026-05-23 Worktree: C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c Status: Apparatus complete. Divergence identified. Fix plan to follow.

This document closes the loop on Step 5 of C:\Users\erikn\.claude\plans\i-did-some-work-sharded-acorn.md. It compares acdream's deterministic-replay output against the retail cdb capture taken at the equivalent scenario, and names the divergence target for the (next) fix plan.

The four prior sessions (2026-05-22 AM + PM, 2026-05-23 AM + PM) shipped 10+ speculative fixes without data. This session shipped the apparatus that turns the next attempt into evidence-driven work (commits 35b37df6f666c1 on top of slice 5's cf3deff).


TL;DR — the divergence target

Retail's BSPLEAF::find_walkable accepts the cottage main floor polygon when the sphere is RESTING ON TOP of it. Sphere local Z = +radius (= +0.48 in the cottage cell). Sphere world Z ≈ 94.48 (cottage floor at world Z=94, plus radius).

acdream's failing-frame sphere is 0.69m BELOW the cottage main floor plane when our walkable query runs. Sphere local Z = -0.6883 in 0xA9B40143. Sphere world Z ≈ 93.31.

Delta: retail's sphere is 1.17 m higher at the equivalent decision point. Either:

  1. Our step-up sequence doesn't lift the sphere high enough before find_walkable is called against the cottage cell, OR
  2. We're calling find_walkable against the cottage cell using the wrong sphere reference (foot-sphere center instead of the step- lifted center), OR
  3. The cellar→cottage transition in retail happens GRADUALLY across many physics ticks (the sphere climbs the ramp one step at a time), and acdream's per-tick climb is too small.

The fix plan needs to choose between (1), (2), and (3) — most likely (3) given retail's BPE-write distribution.

A surprising secondary finding: CPolygon::find_crossed_edge fires ONLY ONCE in 35K probe hits in retail. Our replay harness uses FindCrossedEdge as the primary edge-containment test. Either retail takes a different path through the walkable predicate cascade, or acdream is over-reliant on the edge test for a case retail doesn't hit.


Apparatus shipped this session

Six commits on top of cf3deff (slice 5):

Commit What
35b37df chore(phys): A6.P3 #98 triage — revert neg-poly + bldg-check experiments. Kept: render-vs-physics origin split (GameWindow), terrain-hole cutout, multi-sphere CellTransit, step-walk diagnostic probes. Reverted: neg-poly path split, bldg-check flag, isBuilding propagation, IsLandblockBuilding. Test baseline restored to 1148+8 base.
f62a873 feat(phys): Step 2 — cell-dump probe (ACDREAM_DUMP_CELLS=0xA9B4xxxx,...) + JSON DTOs (CellDump, PolygonDump, etc.) + CellDumpSerializer (Capture / Read / Write / Hydrate) + 4 round-trip tests.
3f56915 capture(phys): Three cell fixtures from live capture — 0xA9B40143 (14 polys), 0xA9B40146 (4 polys), 0xA9B40147 (37 polys). All share worldOrigin=(130.5, 11.5, 94.0) with 180° yaw.
856aa78 test(phys): Step 3 — Issue98CellarUpReplayTests — 7 tests reproducing the live failure pattern deterministically (<1ms per test). Confirms 0xA9B40143 poly 0x0004 rejected at the failing-frame sphere; 0xA9B40146 has no walkable candidate at all.
6f666c1 tools(cdb): Step 4 — issue98-cellar-up-find-walkable.cdb + issue98-runner.ps1 for retail-side capture. BPA/B/C/D/E/F break on find_walkable, walkable_hits_sphere, find_crossed_edge, check_other_cells, set_contact_plane, adjust_sphere_to_plane.
(this doc) Step 5 — divergence comparison.

Raw data — retail cdb capture

Capture: docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.log (decoded: retail.decoded.log)

User ran retail acclient.exe v11.4186 attached via tools/cdb/issue98-runner.ps1 -ScenarioTag "cellar_up_capture_1". They walked up and down a Holtburg cottage cellar stair several times. cdb captured 35,219 BP hits over ~5 seconds of motion.

Hit distribution:

BP Function Hits Notes
BPA BSPLEAF::find_walkable 6,160 per-leaf walkable query
BPB CPolygon::walkable_hits_sphere 7,028 per-polygon overlap test
BPC CPolygon::find_crossed_edge 1 almost never fires!
BPD CTransition::check_other_cells 21,422 outer dispatcher fires very frequently
BPE COLLISIONINFO::set_contact_plane 161 ContactPlane writes
BPF CPolygon::adjust_sphere_to_plane 431 sphere projections

BPE — retail's accepted ContactPlanes

Every one of the 161 BPE writes lands on one of TWO planes:

n=(0, 0, 1)  d=-93.9998  →  world Z=94    (cottage main floor)
n=(0, 0, 1)  d=-90.9500  →  world Z=90.95 (cellar floor)

Retail's ContactPlane is never set to:

  • the cellar ramp (normal ≈ (0, -0.719, 0.695))
  • any of the cellar wall polygons
  • the cellar ceiling (poly 0x0020 in our nomenclature — normal=(0,0,-1) at world Z=93.82)

The transition cellar floor → cottage main floor happens directly: ContactPlane shifts from d=-90.95 to d=-93.9998 with no intermediate plane.

BPA — sphere position at each cottage-floor acceptance

The find_walkable call immediately before each BPE write to the cottage floor shows a consistent sphere position pattern:

BPE hit Last BPA before Sphere LOCAL Notes
#1 hit#435 (cell B) (-0.3270, 0.5998, +0.6300) first cottage-floor accept
#50 hit#2533 (cell B) (-0.3131, 0.7340, +0.6300) cz unchanged
#100 hit#3822 (cell B) (-0.3245, 0.3292, +0.6300) cz unchanged
#160 hit#6159 (cell B) (-0.3195, 0.5271, +0.6300) cz unchanged

Sphere local Z is consistently +0.6300 in cell B at the moment retail accepts. Cell B's cottage floor plane is at local Z=-0.15 (observed from BPB hit#7012 with plane d=-0.15), so the sphere is 0.78m above that floor. Sphere radius 0.48 → sphere bottom is 0.30m above the floor — close enough that walkable_hits_sphere accepts.

The find_walkable hit just BEFORE the cell-B query (hit#433, hit#2532, hit#3820, hit#6158) lands in a different cell ("cell A") at local position ≈ (-11.12, 7.16, +0.48). Cell A's cottage floor plane is at local Z=0 → sphere is 0.48m above (= sphere radius), perfectly resting on the floor.

Both cells consistently see the sphere at local Z = +0.48 to +0.63 at the acceptance moment. Sphere world Z ≈ 94.48 — the sphere has been lifted ABOVE the cottage floor.


acdream replay — sphere position at the equivalent moment

Replay anchor: failing-frame sphere world position (141.7164, 8.3937, 92.0093) r=0.4800, from a6-issue98-negpoly-20260523-135032.out.log line 11338 ([walkable-nearest]) + 11339 ([issue98-walkable-detail]).

In cell 0xA9B40143 (cottage neighbour, 14 physics polys):

sphere LOCAL = (-11.2892, 4.3653, -0.6883)
nearest walkable: poly 0x0004
  plane n=(0,0,1) d=0  (local) →  world Z=94 (cottage floor)
  verts: [(-6.2, 7.6, 0), (-10.0, 7.6, 0), (-10.0, 2.8, 0)]
  signed distance from plane: -0.6883
  abs distance: 0.6883
  gap (abs - radius): 0.2083
  insideEdges: FALSE   (sphere XY beyond triangle edge by 1.29 m on X)
  overlapsSphere: FALSE (|0.6883| > radius 0.48)

In cell 0xA9B40146 (cottage neighbour, 4 physics polys):

sphere LOCAL = (similar)
nearest walkable: NONE
  (the cell has no Z-up polygon close enough to be selected)

In cell 0xA9B40147 (cellar primary, 37 physics polys):

sphere LOCAL = (-11.2164, 3.1063, -1.9907)
nearest walkable: the cellar ramp (poly 0x0008 — n=(0,-0.719, 0.695))
  → accepted as ContactPlane

Our replay confirms the live failure: cottage-cell walkable queries return no usable result; cellar ramp is the only ContactPlane we ever get.


Side-by-side comparison

Field Retail (BPE #1) acdream (negpoly fail)
Sphere world Z 94.48 92.01
Cottage floor plane (world) Z = 94 Z = 94
Sphere position vs cottage floor +0.48 m ABOVE -1.99 m BELOW
Sphere top vs cottage floor +0.96 m above -1.51 m below
Walkable accepted in cottage cell? YES — sphere rests on plane NO — sphere far below plane
ContactPlane set to cottage floor? YES (161 times) NO (never)
find_crossed_edge invocations 1 (in 35K BPs) (used heavily by our walkable test)
check_other_cells invocations 21,422 (per-tick, similar order)

Sphere world Z delta: 2.47 m. Retail's sphere is nearly 2.5 m higher than ours at the equivalent decision point.


Plausible fix targets, in priority order

These are HYPOTHESES — the fix plan must verify each before changing code. Each is testable against the replay harness without launching the client.

Target 1 (highest confidence): step-up + ramp climb doesn't gain enough Z per tick

Retail's data shows the sphere climbs the ramp GRADUALLY across many ticks — BPB hits move smoothly from sphere local Z=-2.57 (resting on cellar floor) through intermediate values up to sphere local Z=+0.48 (resting on cottage floor) over ~7,000 walkable_hits_sphere calls.

Our [step-walk] diagnostic from the failing log shows the sphere oscillating at world Z ≈ 92.0 — never gaining altitude. The ramp's ContactPlane is being set but AdjustOffset is consuming all WalkInterp on the lift, leaving nothing for forward motion (slice 7 handoff's reading was right on this).

Look at:

  • Transition.AdjustOffset — when ContactPlane is the ramp, forward motion should project to ramp-local, gaining Z. Does it?
  • Transition.DoStepUp — when does step-up fire? Is it lifting by the right amount? Compare to retail's step_sphere_up.
  • The interaction between WalkInterp depletion and step-up — does our step-up reset WalkInterp like retail does?

Target 2: cottage-cell candidacy uses wrong sphere reference

Retail iterates cells with the SAME sphere across find_walkable calls in a tick. The sphere position visible to find_walkable for the cottage cell is already at the lifted position. acdream's CellTransit.FindCellSet uses sp.GlobalSphere — but at what tick phase? If we use the pre-step-up sphere center to decide cottage-cell candidacy, but then run the walkable query at the same pre-step-up position, we'll never see the cottage cell as walkable.

Look at:

  • CheckOtherCells in TransitionTypes.cs — what sphere does it pass to BSPQuery.FindCollisions? Does it use the step-lifted position or the pre-step position?
  • The retail oracle CTransition::check_other_cells at acclient_2013_pseudo_c.txt:272717-272798.

Target 3: find_crossed_edge is over-used in our walkable acceptance

Retail's BPC hit count of 1 in 35K is a striking outlier. Either retail's walkable acceptance never needs the edge containment test (because walkable_hits_sphere does enough), or find_crossed_edge is gated behind a different code path we're not hitting.

Look at:

  • BSPQuery.FindCrossedEdge — when is it called? Compare to retail's CPolygon::find_crossed_edge. Maybe we call it in step-up, retail doesn't.

This is a SECONDARY target — not directly the issue #98 failure mode, but a code-shape divergence worth investigating once the primary fix lands.

Target 4 (low confidence): the cellar ramp normal-Z is wrong

If our cellar ramp polygon has a slightly wrong normal compared to retail, AdjustOffset's slope projection would compute different Z gains. The polydump capture shows ramp normal (0, -0.7190, 0.6950); the JSON fixture has the same. Likely not the bug, but worth verifying via dotnet test after any fix attempt.


What the apparatus delivers for future fix attempts

  1. Issue98CellarUpReplayTests runs in <200ms with no client launch. Any change to BSPQuery.FindCrossedEdge, polygon containment, or cell transform shows up instantly.

  2. JSON fixtures in tests/AcDream.Core.Tests/Fixtures/issue98/ are real-geometry captures. Any future fix can call CellDumpSerializer.Hydrate to load them and drive the predicates directly.

  3. tools/cdb/issue98-runner.ps1 is reusable. Any new hypothesis can be re-captured against retail with a 5-minute user action.

  4. tools/cdb/decode_retail_hex.py decodes the hex-bits format — no changes needed.

  5. The retail comparison data is checked into docs/research/2026-05-23-a6-captures/cellar_up_capture_1/ — future analyses can re-grep without re-capturing.


What this plan does NOT do

This document does not ship a fix. The fix is the next plan, scoped to Target 1 (most likely) or Target 2 (next likely). The user should review this divergence reading before authorizing implementation.

Per CLAUDE.md and the systematic-debugging mandate: 4 prior sessions guessed and were wrong. This plan refuses to be the 5th.


Pickup prompt for the fix plan

Open this worktree: C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c

Then:

A6.P3 issue #98 — apparatus complete; ready to write the fix plan.

Read FIRST:
  docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md
  tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs
  docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log

State both altitudes:
  Currently working toward: M1.5 — Indoor world feels right
  Current phase: A6.P3 — fix #98 cellar-up (fix plan)
  Next concrete step: pick Target 1 (step-up Z gain) or Target 2
  (cottage-cell sphere reference) from the comparison doc and write
  the fix plan against it. NO speculative fixes — use the replay
  harness to verify the hypothesis before writing code.

The fix MUST be evidence-driven. The replay harness gives us a 200ms
test loop; a fix that doesn't change the failing assertions in
Issue98CellarUpReplayTests is not the fix.

Test baseline: 1167 + 8 (with apparatus). Maintain through any fix.
CLAUDE.md rules apply. No workarounds without explicit approval.