acdream/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md
Erik 67f98e8e72 docs(#145): verified root cause + decision to port cell-relative physics frame (Option B) + handoff
Workflow wf_87607d15-c43 (4 research streams + synthesis + 3 adversarial verifiers, HIGH confidence) confirmed the far-town runaway is a cell-membership label cascade from a discarded TryGetTerrainOrigin bool (CellTransit.cs:736 -> (0,0) origin for unstreamed neighbors), not a free-fall; 17410 is a wire artifact. User chose the architectural fix: port retail's cell-relative Position + retire _liveCenter from physics. Handoff doc carries the verified mechanism, the retail port table (decomp addresses), acdream divergence sites, apparatus (desync-capture.jsonl + probes + harness template), and the brainstorming-gate requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:47:01 +02:00

198 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Handoff — #145-residual: port the physics frame to CELL-RELATIVE (retail-faithful), retire the streaming center from physics
**Date:** 2026-06-21
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main** (user's call: "we can merge later")
**Milestone:** M1.5 "Indoor world feels right" (this is the last blocker for far-town/dungeon round-trips)
**HEAD at handoff:** `a4f0b51` (the #145 reopen) + an uncommitted ISSUES.md #145 root-cause update (committed alongside this doc).
**DECISION (user, 2026-06-21):** Go with **Option B — the architectural fix.** Make the physics position
**cell-relative like retail** and retire the streaming `_liveCenterX/_liveCenterY` from the physics path
(render-only). The user explicitly rejected the targeted patch (Option A) in favor of "done right, never
again." Quote: *"It should be agnostic."* **Start with a brainstorming gate** (this is a multi-commit
physics-frame phase touching the physics↔streaming seam broadly).
---
## What shipped this session (context — already committed)
| Commit | What |
|---|---|
| `bf66fb4` | **#138 FIXED + user-verified** — server objects re-hydrate from the retained spawn table (`_lastSpawnByGuid`) on landblock reload; ACE won't re-send known objects, so we re-project. Register AP-48. |
| `0a5f91b` | #138 secondary — rescue persistent entities from the pending bucket on unload (player-vanish candidate). |
| `aa4a04d`/`b07825c` | #138 docs + handoff correction (the prior handoff's `ClientObjectTable` advice was WRONG — it's inventory-only; `_lastSpawnByGuid` is the world-object table). |
| `a4f0b51` | **#145 REOPENED** — far-town teleport resolver runaway (residual of the 2026-06-20 source-drop fix). |
**#138 is DONE and independent of this work** — the re-hydrate (objects come back) is user-confirmed and
the capture proved it is NOT involved in the runaway. It's mergeable as-is.
---
## THE BUG (verified — multi-agent workflow `wf_87607d15-c43` + 3 adversarial verifiers, all `holdsUp=true`, confidence HIGH)
**Full report:** `…/tasks/w8wm5fln4.output` (synthesis + 3 verdicts) and the workflow transcript dir.
Distilled:
**It is a cell-membership LABEL cascade, NOT a real free-fall.** Teleport to a FAR town (e.g.
(201,91)=`0xC95B`) places the player CORRECTLY at `0xC95B0001` local `(14.8, 0.3, 12)` (the #145 verbatim
path). Then the per-frame resolve **marches the cell id one landblock south per physics quantum (~33 ms)**
while the **physics body barely moves** (capture: max|Y|≈86 m, Z falls to ≈135 then walk-back; settles at
the TRUE rest `(-6.124, -30.186, 12.000)`, og=True). The cell-id `lbY` byte counts `0x5B`(91)→`0x00`. The
**`17410`** ACE rejects is a **wire-conversion artifact** — once `lbY` marched to 0, the outbound
`localY = Position.Y (lbY _liveCenterY)·192` back-adds 91×192 = 17472. The body never went there.
**Proximate cause (single source line):** `src/AcDream.Core/Physics/CellTransit.cs:736`
```csharp
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); // bool DISCARDED
```
For a far town the player is placed at the southern landblock edge (world-Y≈0.3) with **stale southward
running velocity** (the teleport never idled the motion state). The first tick crosses Y=0 into landblock
`0xC95A`, which **has not streamed in yet**`TryGetTerrainOrigin` returns **`false` with
`origin=Vector3.Zero`** (`CellGraph.cs:47-56`; the comment at `CellTransit.cs:732-735` literally
self-documents this "legacy anchor-frame assumption (world frame == block-local frame)"). `(0,0)` flows into
`AddAllOutsideCells` (`:758`) → `GetOutsideLcoord` (`LandDefs.cs:110-111`) does
`ly += floor(worldY/24)` = `floor(-0.088/24) = -1` → cell marches one block south. The CORRECT origin for
`0xC95A` is `(0,-192)`, which would give `floor(191.9/24)=+7` (right region). **The missing 192 rebase is
the bug.** Every next neighbor is also unstreamed → the `(0,0)` cascade self-perpetuates 91×.
**Why FAR-TOWN only:** Holtburg is the streaming startup center *only because +Je spawns there* (the center
recenters to the login landblock at `GameWindow ~:2879` and on every teleport at `~:5447` — it is NOT
hardcoded to Holtburg). Holtburg works because its neighbors are already streamed (registered), so the
`(0,0)` fallback never fires, and its spawns are mid-block (no Y=0 crossing to arm the cascade). The bug
fires at the EDGE of the streamed region for ANY fresh teleport. **This is the non-agnostic assumption the
user flagged: `(0,0)` = "this landblock is home" — only true at the center.**
**#138 re-hydrate EXONERATED:** it fired only for Holtburg + the dungeon (log lines 412/498/710), never for
the far town during the runaway (746+); it adds only RENDER entities, never the physics `_landblocks` the
resolver iterates.
---
## THE FIX — Option B: cell-relative physics `Position` (port retail)
**The retail design (decomp-verified, addresses re-confirmed against live Ghidra patchmem :8081):**
retail stores EVERY physics position **cell-relative** and re-bases the stored origin **in place** on every
placement and every boundary crossing, so the (cell, local) pair can never drift apart. There is **no
streaming "center" anywhere in retail's physics.** Make acdream's physics match this; `_liveCenterX/Y`
becomes a **render-only** concern (the `(lb _liveCenter)·192` math is correct for placing meshes around
the camera, but must NOT be the physics authority).
### Retail functions to port (named decomp + Ghidra — verified addresses)
| Retail symbol | Address | Role |
|---|---|---|
| `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30659` | The position IS the (cell, local) pair. `Frame.m_fOrigin` (`acclient.h:30654`) is LOCAL to the cell, bounded `[0,192)` for outdoor. **No global/world position field exists.** |
| `Position::adjust_to_outside` | `0x00504A40` | Rebases the ACTUAL `this->frame.m_fOrigin` + `this->objcell_id` in place (not a temp). |
| `LandDefs::adjust_to_outside` | `0x005A9BC0` | Recomputes global landcell via `get_outside_lcoord`, rebuilds cell id via `lcoord_to_gid`, **wraps origin into [0,192)** (`origin -= floor(origin/192)*192`). The canonicalizer. |
| `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. (acdream `LandDefs.cs:105-112` is a faithful port — but the per-frame pick feeds it a RAW world-frame Y via the `(0,0)` fallback instead of a wrapped local.) |
| `LandDefs::get_block_offset` | `0x0043E630` | The ONLY cross-cell translation: `(dXsX)·24, (dYsY)·24` from the two cell ids' landblock bytes; **returns ZeroVector when both cells share a landblock**. A delta of two NAMED cells — never an accumulation vs a moving center. |
| `LandDefs::blockid_to_lcoord` / `lcoord_to_gid` | `0x0043D680` / `0x004A19A0` | Absolute global landcell grid (lcoord 0..0x7f8). No `_liveCenter` subtraction anywhere — the grid origin is the world origin, fixed. |
| `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **together** (lockstep), then `cache_global_curr_center`. The local frame is re-expressed into the destination cell during the sweep. |
| `CPhysicsObj::AdjustPosition` / `SetPositionInternal` | `0x00511D80` / `0x00515BD0` | Every placement/teleport runs `adjust_to_outside` → an inconsistent (lbY=0, localY=17410) pair cannot persist. |
| `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the transient working "global" sphere center from `frame.m_fl2gv·local + frame.m_fOrigin` — cell-local only, NO landblock-world offset, rebuilt from `curr_pos` each step. |
acdream **already ports** `AdjustToOutside`/`GetOutsideLcoord`/`LcoordToGid`/`BlockidToLcoord` in
`src/AcDream.Core/Physics/LandDefs.cs` (from the #106 work) — WITH the [0,192) wrap (`LandDefs.cs:130-145`).
The gap is that the per-frame outdoor resolve (`CellTransit.BuildCellSetAndPickContaining`) does NOT run the
wrap+rebase; it re-derives `lbPrefix` from a world-XY scan against a `(0,0)`-or-baked-offset origin.
### acdream divergence sites (the streaming-relative physics frame — what to change)
- **The frame itself:** physics position is a streaming-relative world `Vector3`
(`worldXY = (lb _liveCenter)·192 + local`). Sites: `PhysicsEngine.cs:603-614` (the `foreach(_landblocks)`
outdoor membership scan), `:624`, `:858-860` (returns `lbPrefix | (targetCellId & 0xFFFF)`).
- **Baked per-landblock WorldOffset** computed against the MOVING center at load: `GameWindow.cs` origin
computation `~:4992` (and the WorldOffset inverse used outbound at `~:2930-2931` / `~:4992-4993` — note the
synthesis cited `:7763-7765` for the outbound `localY` conversion but a verifier could not locate that
exact line; **re-locate the outbound (cell, local) builder** — it's where `localY = Position.Y (lbY
_liveCenterY)·192` happens).
- **The `(0,0)` fallback** (proximate trigger): `CellTransit.cs:736` (discarded bool) → consumed at the seed
`AddAllOutsideCells` (`:758``:242`), the containing-pick (`:845`), and the other seed (`:484`).
`CellGraph.cs:47-56` (`TryGetTerrainOrigin` returns `(Zero,false)` for unregistered terrain).
- **The teleport that leaves running velocity live:** `src/AcDream.App/Input/PlayerMovementController.cs`
(NOTE: App/Input, not Core/Physics) — `SetPosition` zeros velocity `~:803` but the same-frame motion path
re-applies the running vector `~:943-980`. A teleport arrival should call the motion-idle path.
- `TransitionTypes.cs ~:2315` (`SetCheckPos` commits the marched cell id without moving position).
- **The recenter:** `GameWindow.OnLivePositionUpdated ~:5447` (drop-source + `_liveCenterX/Y=lbX/lbY`) and the
login recenter `~:2879-2909`. These STAY (render frame), but physics must stop depending on them.
### Shape of the port (brainstorm this — do not just start coding)
The end state: a physics `Position { uint ObjCellId; Frame{ Vector3 LocalOrigin∈[0,192) for outdoor; Quaternion } }`,
carried by the player/physics body instead of a streaming-relative world `Vector3`. Cross-cell offsets come
ONLY from `get_block_offset(cellA, cellB)` (port `0x0043E630`). Every `SetPosition`/teleport/cross-landblock
move runs `AdjustToOutside` to re-wrap. `_liveCenterX/Y` is used by the RENDERER (and the outbound wire
conversion derives the wire (cell, local) directly from the cell-relative Position — no center math needed,
which kills the 17410 leak at the source). The streaming `WorldOffset` baked per-landblock can stay for the
RENDER mesh placement but the physics resolve must not read it.
**This is a large, seam-crossing change.** It touches `PhysicsEngine`, `CellTransit`, `TransitionTypes`,
`CellGraph`, the player controller, the outbound wire builder, and every test that constructs a physics
position. Scope it via `superpowers:brainstorming` FIRST, then write a plan, then execute in reviewable
slices (likely: (1) introduce cell-relative `Position` alongside the world frame; (2) port `get_block_offset`
+ route the resolve through it; (3) make `AdjustToOutside` run on the per-frame path; (4) flip the player
body to cell-relative; (5) make the outbound wire derive from it; (6) make `_liveCenter` render-only; (7)
delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existing
`CellarUp`/`DoorBug`/membership harnesses must stay green.
---
## Apparatus (use this — do NOT rebuild)
- **The captured runaway:** `desync-capture.jsonl` (worktree root, 72,401 `ResolveWithTransition` records:
`input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId}`,
`bodyBefore/After{position,orientation,velocity}`, `result`). Runaway records: cellId `0xC95B0001``0xC9000008`
(decimal 3372220424). Velocity at first far-town record = `(3.637,11.276,0)` (the stale run).
- **Probes (env vars, no rebuild):** `ACDREAM_PROBE_CELL=1``[cell-transit] old->new pos=(x,y,z) reason=`;
`ACDREAM_CAPTURE_RESOLVE=<path>` → the JSON capture; `ACDREAM_PROBE_RESOLVE=1` → per-resolve `[resolve]`
lines; `ACDREAM_DUMP_LIVE_SPAWNS=1` → spawn + re-hydrate + recenter lines.
- **The session log of the repro:** `…/tasks/bu932mwj3.output` (cell-transit trail + `[snap]` + teleport
arrivals + the healthy Holtburg cell-transits for comparison).
- **Test harness template:** build `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` from the
capture (apparatus = `PhysicsResolveCapture` + the `CellarUpTrajectoryReplayTests` `LiveCompare_*` pattern).
Assert: with `0xC95A` unregistered, the cell-relative resolve keeps cell+local consistent (no march). Include
a non-south case (east-edge spawn) — the cascade is direction-agnostic.
## Open items / risks / DO-NOT-RETRY
- **Verifier corrections to the synthesis prose (use the corrected facts):** march rate is ~1 landblock per
physics QUANTUM (~33 ms), not per render frame (~40 capture records per crossing); `max|Position.Y|≈85.67`
(not 61.9); the true settle is `(-6.124,-30.186,12.000)` og=True (not `(21.87,-21.62,-6.50)` — that's a
mid-fall entry); `PlayerMovementController.cs` is under `src/AcDream.App/Input/`. The MECHANISM is unchanged.
- **DO-NOT-RETRY:** the physics digest DO-NOT-RETRY table has NO conflict (it covers cellar-up/door/lip
wedges — different code path). But **do NOT attempt a membership "stickiness"/hysteresis fix** — the table
explicitly disproves stickiness for the lip path; the correct shape is cell-relative storage, not hysteresis.
Also the digest already names this seam: `TryGetTerrainOrigin` "Zero fallback = legacy anchor-frame" + the
[[feedback_latent_bug_masked_by_fallback]] lesson ("a fallback masked a production bug") — this is that
pattern recurring. Read the physics digest first.
- **Map-edge towns:** confirm a destination at the world edge (a needed neighbor landblock genuinely doesn't
exist) resolves sanely under the cell-relative frame (retail handles this via the absolute lcoord grid —
`get_outside_lcoord` clamps; verify the acdream port matches).
- **Outbound wire:** after the port, the wire (cell, local) must derive from the cell-relative Position
directly. Re-locate the current outbound builder (the synthesis's `:7763-7765` was unverified).
---
## Launch / probes / accounts (unchanged)
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!'
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_CAPTURE_RESOLVE = "<worktree>\desync-capture2.jsonl"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
Account `notan`/`MittSnus81!``+Je` (guid `0x50000001`), spawns Holtburg, reaches the 0x0007 Town Network
hub → portals to far towns (the repro: hub → any far town → the runaway arms when you arrive still running).
**The user drives the client lifecycle** — do NOT kill/close clients; `dotnet run` when asked, read logs.
**Visual confirmation requires the user** (the one genuine stop point).
## Pointers
- ISSUES `#145` (reopened — full verified root cause + the A-vs-B decision), `#138` (DONE).
- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` — read FIRST.
- Workflow output (full synthesis + 3 verdicts): `…/tasks/w8wm5fln4.output`; transcripts under
`…/subagents/workflows/wf_87607d15-c43/` (the retail-oracle agent's report is the decomp goldmine).
- Register: AP-36 (the streaming-center gate) + AP-48 (#138 re-hydrate) — the B port will retire/rewrite the
physics half of the streaming-relative-frame divergence; add/update register rows in the porting commits.
- References: `named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 (the oracle);
`references/ACE` + `references/holtburger` (the wire/client frame cross-check).