# A6.P3 #98 — Trajectory Replay Harness handoff

**Session:** 2026-05-23 (full day, 10+ commits)
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\strange-albattani-3fc83c`
**Branch:** `claude/strange-albattani-3fc83c`

This handoff documents the apparatus committed this session, the things we
learned, the things we ruled out, and the concrete next-session pickup move.
Read this first when you resume.

---

## TL;DR

- **#98 is NOT fixed.** Six fix-shape attempts across this saga (4 prior
  sessions + 1 this session's Shape 1) all failed or got reverted.
- **The trajectory replay harness is REAL but blocked.** Mechanically
  works — runs 200 physics ticks in <100 ms against pre-loaded cell
  fixtures. Blocked on a NEW second bug we surfaced during harness
  commissioning (airborne-at-tick-1).
- **The cellar ramp polygon is NOT in the cell** — it's in a separate
  GfxObj (a static building piece) registered as a ShadowEntry. The
  harness reconstructs the ramp polygon programmatically from the live
  capture's polydump data.
- **Per the systematic-debugging skill: 6 hypotheses tested without
  convergence = stop and reflect.** The next-session move is NOT
  another speculative fix attempt — it's a side-by-side comparison
  harness against live PlayerMovementController state.

---

## What ran this session (chronological, 10 commits)

| Commit | What |
|---|---|
| `8a232a3` | `[step-walk-adjust]` probe inside `Transition.AdjustOffset` — names which projection branch fires per call + Z gain |
| `8daf7e7` | Findings note + capture snapshot. **AdjustOffset projection is CORRECT** — sphere climbs 90.95 → 92.80 monotonically. Caps at top of ramp because step-up rejects (cottage floor is ABOVE not below). |
| `0cb4c59` | Shape 1 fix attempt: gate `BSPQuery.AdjustSphereToPlane`'s two `SetContactPlane` call sites by `worldNormal.Z >= 0.99`. |
| `402ec10` | Revert Shape 1 — broke OnWalkable for all sloped walkable surfaces (74% of live capture lines in falling state). |
| `5f3b64c` | Session-pause handoff in ISSUES.md + CLAUDE.md. |
| `4c9290c` | Trajectory replay harness (PhysicsEngine + PhysicsDataCache + PhysicsBody + cell fixtures). Mechanics validated. |
| `3d2d10b` | Harness extension: programmatic synthetic stair GfxObj + ShadowEntry. **Discovery:** ramp polygon lives in GfxObj, not cell. |
| `227a775` | Diagnostic dump + 0.05m initial Z lift experiment. Same airborne behavior. |
| `5c6bdbe` | Deep investigation: 6 hypotheses tested via the harness, none isolated root cause of (0,1,0) hit at tick 1. |

---

## What the harness IS (committed apparatus)

[`tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs`](../../tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs)

A deterministic trajectory replay that:

1. Loads three issue-#98 cell fixtures (cellar + 2 cottage neighbors) via `CellDumpSerializer.Hydrate`.
2. Wraps each cell with a synthetic single-leaf `PhysicsBSPTree` (`AttachSyntheticBsp`) — needed because Hydrate sets BSP=null and without BSP the indoor branch is skipped.
3. Registers the cellar's stair-ramp polygon as a synthetic `GfxObjPhysics` (`RegisterStairRampGfxObj`) — polygon vertices in WORLD coordinates so the ShadowEntry registers at origin with identity rotation/scale.
4. Constructs a `PhysicsBody` seeded with:
   - `ContactPlaneValid=true`, `ContactPlane=(0,0,1,-90.95)` (cellar floor plane)
   - `WalkablePolygonValid=true`, `WalkableVertices` = cellar floor poly under sphere XY
   - `TransientState = Contact | OnWalkable`
5. Drives N ticks of `PhysicsEngine.ResolveWithTransition` with a constant -Y forward offset (`PerTickOffset = (0, -0.1, 0)`).
6. Returns a per-tick `TrajectoryPoint` list (Tick, Position, CellId, IsOnGround, CpValid).

5 tests, all passing in ~75 ms total. Baseline maintained at 1167 + 5 (harness) = 1172 + 8 pre-existing failures.

### Reusable helpers in the harness

| Helper | Purpose |
|---|---|
| `BuildEngineWithCellarFixtures()` | Full engine setup — cells + synthetic BSPs + (optional) stair GfxObj |
| `AttachSyntheticBsp(CellPhysics)` | Wraps a hydrated cell with a one-leaf BSP referencing every Resolved polygon. **Reusable for any indoor-cell test that needs the indoor BSP path to fire.** |
| `RegisterStairRampGfxObj(engine, cache)` | Constructs a programmatic GfxObj + ShadowEntry for the cellar ramp polygon. **Reusable for any indoor-static-collision test.** |
| `BuildInitialBody()` | PhysicsBody with both ContactPlane AND WalkablePolygon seeded. **The seeding pattern is the discovery** — both must be set or the engine treats the sphere as "grounded but anchorless." |
| `SimulateTicks(engine, body, cellId, N)` | Per-tick driver with proper cross-tick PhysicsBody state. |

---

## Bug 1: #98 — cellar-up freeze (UNFIXED)

The original bug. Sphere climbs the cellar ramp partway (world Z 90.95 → 92.80) then caps. Cottage floor at world Z=94 still 1.2m above.

**Refined diagnosis from this session's `[step-walk-adjust]` probe:**
AdjustOffset's slope projection is CORRECT — 145/146 calls take `into-plane` branch with mean +0.045 m zGain per call. The cap happens because step-up's downward step-down probe at the ramp top finds no walkable surface below (cottage floor is ABOVE). 101 `stepdown-reject` vs 1 acceptance.

**Six fix shapes attempted across the saga, all failed:**
1. Placement-insert bypasses (slice 6, 6 variants)
2. Cell-resolver tiebreaker changes (slice 3)
3. Negative-side polygon handling (slice 7, reverted)
4. Building-check / IsLandblockBuilding flag (slice 7, reverted)
5. Multi-cell BSP iteration (A4, shipped but doesn't address top-of-ramp)
6. **Shape 1: gate ContactPlane assignment by Normal.Z ≥ 0.99** (this session — broke OnWalkable, reverted)

---

## Bug 2: Airborne-at-tick-1 (NEW, surfaced this session)

When the trajectory replay harness drives ResolveWithTransition with a sphere seeded grounded on the cellar floor, **tick 1 reports `hit=yes n=(0,1,0) walkable=False/True` and the body goes airborne**. The sphere then floats horizontally over the cellar floor for the rest of the simulation, never touching the ramp.

This is **structurally different** from #98:
- #98 fails MID-CLIMB at the top of the ramp
- This bug fails AT START — sphere can't even walk a flat floor

This bug blocks the harness from reproducing #98 in test isolation. It must be solved before the harness can drive #98 fix attempts.

### Confirmed via investigation (committed in 5c6bdbe)

| Hypothesis | Outcome |
|---|---|
| WalkablePolygon NOT seeded in body | PARTIAL FIX — `walkable=True` survives but (0,1,0) hit still appears |
| Initial sphere Z lift 0.0 vs 0.05m | NO — same hit either way |
| Synthetic stair GfxObj triggering wall hit | NO — same hit without stair |
| Stub landblock terrain at Z=0 triggering hit | NO — same hit without landblock |
| Cell BSP=null falling through to terrain | NO — same hit with synthetic BSP attached |
| `body=null` vs body-with-CP-seed | NO — same hit either way |

### What we know about the (0,1,0) hit

- It's a +Y world normal — doesn't match any registered geometry (the stair has normal (0, 0.719, 0.695), the cellar floor has normal (0,0,1), the cellar walls have normal in the X/Y/Z axis directions but at known positions far from the sphere).
- It appears at the `after-validate` step-walk probe site — set BY ValidateTransition between `after-insert` and `after-validate`.
- `ValidateTransition`'s default-fallback line sets UnitZ=(0,0,1), not UnitY=(0,1,0). So something INSIDE TransitionalInsert set `ci.CollisionNormal=(0,1,0)` before ValidateTransition ran.
- 12 different `SetCollisionNormal` call sites in TransitionTypes.cs — root cause not isolated to one.

---

## DO NOT DO (next session)

The 5-attempt-failure pattern from #98 saga + this session's 6-hypothesis-failure on the airborne bug = **a long list of dead ends**. Don't retry any of these:

For #98 itself:
- Placement-insert bypasses in `BSPQuery.FindCollisions` / `Transition.FindEnvCollisions` / `Transition.DoStepDown`
- Cell-resolver tiebreaker changes in `PhysicsEngine.ResolveCellId` (slice 3 already shipped a fix)
- Negative-side polygon handling
- bldg-check / IsLandblockBuilding flag propagation
- Gating ContactPlane assignment by Normal.Z in `BSPQuery.AdjustSphereToPlane` (Shape 1 — breaks OnWalkable for sloped walkables)
- Any suppression flag, grace period, retry loop, or `if (problematicState) return early` workaround

For the airborne bug:
- Re-attempting any of the 6 hypotheses listed above
- Speculation about init fields without comparing to a live capture
- Adding more probes randomly — we already have 4+ probes wired

---

## What apparatus exists to use

| Tool | Location | Purpose |
|---|---|---|
| `[step-walk]` probe | TransitionTypes.cs (many call sites) | Per-step-site full state dump |
| `[step-walk-adjust]` probe | TransitionTypes.cs:AdjustOffset | Per-AdjustOffset call branch + zGain |
| `[resolve]` probe | PhysicsEngine.cs end of ResolveWithTransition | Per-call input/output/hit/cp summary |
| `[indoor-bsp]` probe | TransitionTypes.cs:1917-1926 | Per-indoor-BSP-call summary (only when BSP non-null) |
| `[poly-dump]` probe | BSPQuery.cs:402 | Per-AdjustSphereToPlane polygon hit dump |
| `[push-back]` probe | BSPQuery.cs:354-394 | Per-push-back motion details |
| `[place-fail]` probe | TransitionTypes.cs:2908 | Per-DoStepDown placement_insert rejection |
| `Issue98CellarUpReplayTests` | tests/.../Physics/ | 7 tests, single-frame failing-frame geometry |
| `CellarUpTrajectoryReplayTests` | tests/.../Physics/ | 5 tests, N-tick trajectory harness |
| Cell fixtures | tests/.../Fixtures/issue98/*.json | 3 hydratable cells (cellar + 2 cottage neighbors) |
| Retail cdb captures | docs/research/2026-05-23-a6-captures/ | Multiple capture sessions, decoded |
| cdb scripts | tools/cdb/*.cdb + tools/cdb/*.ps1 | Re-runnable retail-side capture infrastructure |

---

## Recommended next-session move

**Build a side-by-side comparison harness against live PlayerMovementController state.**

Concretely:

1. In the live client, attach a probe to `PlayerMovementController.cs:1105-1129` (the production ResolveWithTransition call site) that captures the FULL state passed in (every PhysicsBody field, sphere radius/height, step heights, mover flags, entity id) and the FULL state returned (ResolveResult fields, body state after the call).
2. Walk in a Holtburg cottage cellar. Capture 2-3 ticks of full state.
3. Save the capture as a JSON fixture in `docs/research/`.
4. Add a test to `CellarUpTrajectoryReplayTests.cs` that loads that fixture and feeds the EXACT captured state into ResolveWithTransition. Compare per-field divergence between the captured `ResolveResult` and the harness's result.
5. The divergence WILL exist (otherwise we wouldn't have the airborne bug). The first divergence pinpoints the missing state init step.

This approach is **evidence-driven, not speculation-driven**. The whole reason the 6-hypothesis investigation failed is we kept guessing what the harness was missing. A live capture tells us directly.

**Estimated effort:** 1 hour to wire the production-side probe + capture + JSON dump; 30 min to write the comparison test; 30 min to analyze the first divergence. Total ~2 hours, then the airborne bug should be solvable.

---

## Alternative next-session moves

If the comparison harness investment feels too big, here are smaller alternatives:

1. **Pivot to a different M1.5 issue.** The cellar-up demo isn't the only M1.5 critical path. Other issues in `docs/ISSUES.md` that need work: chronic open issues (#2, #4, #28, #29, #37, #41), the #90 workaround removal (now redundant after slice 3), or one of the Phase C visual fidelity items. Less coupling, faster forward progress.

2. **Pivot to M2 prep.** M1.5 is blocking M2 by policy ("one active milestone at a time"). But if the user authorizes, M2 has nicer scope — inventory panel (F.2), combat math (F.3), dev panels (F.5a). Visible wins, no physics rabbit holes.

3. **Use the harness elsewhere.** The `RegisterStairRampGfxObj` + `AttachSyntheticBsp` patterns are reusable for ANY indoor-static-collision test. If there's a different bug (corpse pickup boundary, door swing collision, etc.) that needs deterministic testing, the harness's apparatus is ready.

---

## Pickup prompt for next session

```
A6.P3 #98 trajectory harness — session paused 2026-05-23.

Read FIRST:
  docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md (this file)
  tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
  (especially the class-doc comment + the 5 [Fact] tests)

State both altitudes:
  Currently working toward: M1.5 — Indoor world feels right
  Current phase: A6.P3 — trajectory replay harness, blocked on a SECOND
  bug (airborne-at-tick-1) that surfaced during commissioning. The
  original #98 cellar-up freeze remains unfixed; the harness needs
  the airborne bug solved before it can drive #98 fix attempts.

The handoff doc has three options for what to do next:

(A) Build the side-by-side comparison harness — capture live
    PlayerMovementController state, replay in test, diff. ~2 hours.
    Most retail-faithful path. Recommended.

(B) Pivot to a different M1.5 issue (chronic open issues, #90 removal,
    Phase C work). Less coupling, faster wins.

(C) Pivot to M2 prep (requires user authorization — M2 is policy-deferred
    until M1.5 lands).

Pick A, B, or C. If A: there's a step-by-step plan in the handoff
doc's "Recommended next-session move" section.

CLAUDE.md rules apply throughout. NO speculative fixes — the saga has
six failed shapes already. Evidence first.

Test baseline: 1172 + 8 (pre-existing failures). Maintain throughout.
```
