# 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: `(dX−sX)·24, (dY−sY)·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=` → 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 = "\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).