# 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`](../../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 `35b37df` → `6f666c1` 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`](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`](../../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. ```