# Design — #145 Option B: cell-relative physics `Position` frame (retire `_liveCenter` from physics) **Date:** 2026-06-21 **Issue:** #145 (far-town teleport resolver runaway — residual of the 2026-06-20 source-drop fix) **Milestone:** M1.5 "Indoor world feels right" (last blocker for far-town / dungeon round-trips) **Status:** APPROVED design — ready for implementation plan **Decision authority:** user, 2026-06-21 ("Lock in B") **Supersedes the targeted patch (Option A):** rejected by the user in favor of the architectural fix ("It should be agnostic." / "done right, never again.") --- ## 1. Problem (verified root cause — condensed) A teleport to a FAR town places the player correctly, then the per-frame outdoor resolve **marches the cell-id LABEL one landblock south per physics quantum (~33 ms)** while the **body barely moves**. It is a cell-membership *label* cascade, NOT a free-fall. The `17410` ACE rejects is a wire-conversion artifact. **Single proximate trigger:** `CellTransit.cs:736` discards the `bool` from `CellGraph.TryGetTerrainOrigin`. For an unstreamed neighbor that returns `(Vector3.Zero, false)`, and the resolve uses `(0,0)` as the block origin ("legacy anchor-frame" fallback, self-documented at `CellTransit.cs:732-735`). `GetOutsideLcoord` then does `floor(-0.088/24) = -1`, so the cell marches; the correct origin `(0,-192)` would give `+7`. Far-town-only because the streaming center follows login/teleport, so a fresh teleport lands at the *edge* of the streamed region where neighbors are unregistered. **The non-agnostic assumption:** acdream's physics position is a **streaming-relative world `Vector3`** (`worldXY = (lb − _liveCenter)·192 + local`). The `(cell, local)` pair is *reconstructed* from a moving reference (`_liveCenter`) every frame, so it can drift apart. Retail's cannot — see §2. Full verified mechanism + apparatus: `docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md` and the physics digest (`claude-memory/project_physics_collision_digest.md`, top banner). ## 2. The retail architecture (decomp-verified) Retail has **no world/global physics frame anywhere.** Every stored spatial quantity carries the cell it lives in, and per-tick collision is computed in a working frame **anchored at the player's current landblock**. There is no streaming center. | Retail symbol | Address | Verified role | |---|---|---| | `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30658` | The position IS the `(cell, local)` pair. | | `struct Frame { float qw,qx,qy,qz; float m_fl2gv[9]; Vector3 m_fOrigin }` | `acclient.h:30647` | `m_fOrigin` is LOCAL to the cell's landblock — outdoor `X/Y ∈ [0,192)`, `Z` = height. `m_fl2gv` = 3×3 local→global rotation. | | `LandDefs::get_block_offset` | `0x0043E630` | **The ONLY cross-cell translation.** Verified body (pc:69189): `if ((src>>16)==(dst>>16)) return Zero;` else `x=((dstLbX<<3)−(srcLbX<<3))·24`, `y=((dstLbY<<3)−(srcLbY<<3))·24` → **nets to `(Δlandblock)·192 m` per axis**. (`block_byte<<3` = lcoord units; `·24 m/cell`.) Degenerate `arg==0` fallbacks present — port verbatim. A delta of two NAMED cell ids — never an accumulation vs a moving center. | | `LandDefs::adjust_to_outside` | `0x005A9BC0` | The canonicalizer. Wraps `m_fOrigin` into `[0,192)` and rebuilds `objcell_id` from the FIXED absolute lcoord grid (no terrain registration). **Already ported** at `LandDefs.cs:124-142` WITH the `[0,192)` wrap (`:134-135`). | | `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. **Already ported** `LandDefs.cs:105-113`. | | `Position::adjust_to_outside` | `0x00504A40` | Rebases `this->frame.m_fOrigin` + `this->objcell_id` IN PLACE (calls `LandDefs::adjust_to_outside`). | | `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the working sphere center: `m_fl2gv·local + m_fOrigin` — cell-landblock-local; rebuilt from `curr_pos` each step. | | `SPHEREPATH::get_curr_pos_check_pos_block_offset` | `0x00509FD0` | `get_block_offset(curr_pos.objcell_id, check_pos.objcell_id)` — the curr↔check working-frame offset. | | `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **in lockstep**, then caches the global center. The label and local position can never be adopted separately. | | contact-plane translation | `0x0050A592` | `get_block_offset(sphere_path.check_pos.objcell_id, contact_plane_cell_id)` then offsets the contact plane — **iff `contact_plane_cell_id != 0`**. Confirms retail stores the contact plane in ITS cell's frame + a cell id, translated into the working frame per tick. This is the Approach-B model. | **The invariant that makes the cascade structurally impossible:** `m_fOrigin` is ALWAYS `[0,192)` and `objcell_id` is the container — neither is reconstructed from a moving reference. An inconsistent `(cell, local)` pair cannot persist because `adjust_to_outside` re-canonicalizes on every placement and boundary crossing. ## 3. acdream end-state design ### 3.1 New value types (`AcDream.Core.Physics`) ```csharp public readonly record struct Frame(Vector3 Origin, Quaternion Orientation); // Origin is LOCAL to ObjCellId's cell: // outdoor → Origin.X/Y ∈ [0,192) within the landblock; Origin.Z = height. // indoor → Origin is the EnvCell-relative placement the BSP path already uses. public readonly record struct Position(uint ObjCellId, Frame Frame); ``` One `Position` type covers indoor and outdoor (retail uniformity). `PhysicsBody.Position` becomes a `Position` and is **the source of truth.** ### 3.2 The seam: `_liveCenter` is render-only A single conversion lives on the **render** side: `WorldFromPosition(Position, renderCenter) -> Vector3`. Render/camera/mesh placement keep using `_liveCenterX/Y` + the per-landblock baked `WorldOffset`. **Physics never reads `_liveCenter` or `WorldOffset`.** During migration, `PhysicsBody.WorldPosition` is a computed accessor (`WorldFromPosition`) so render and wire keep working slice-by-slice; it is deleted from the physics read paths by the end. ### 3.3 The per-tick working frame The collision sweep computes in a `Vector3` frame **anchored at `curr_pos`'s landblock**, derived from `ObjCellId` via the fixed lcoord grid (NOT `_liveCenter`). Geometry in a neighbor landblock is pulled in via `GetBlockOffset(currBlock, otherBlock)`. This is exactly `cache_global_curr_center`. Inter-tick collision state — contact plane + walkable polygon — is stored **in its owning cell's frame + its cell id** (`ContactPlaneCellId` already exists, `PhysicsBody.cs:136`) and translated into the working frame per tick via `GetBlockOffset` (retail `0x0050A592`). ### 3.4 Canonicalization + lockstep `AdjustToOutside` runs on every `SetPosition` / teleport / cross-landblock step (`Position::adjust_to_outside` shape). `validate_transition` adopts `check_pos.ObjCellId` AND `check_pos.Frame` together. `curr_pos` / `check_pos` in `TransitionTypes` become `Position`. ## 4. Divergence sites → what changes | Site | Today | Change | |---|---|---| | `PhysicsBody.cs:95` | `Vector3 Position` (world) | `Position` (cell-relative) + computed `WorldPosition` | | `ResolveResult.cs:21-37` | `(Vector3 Position, uint CellId)` | returns a `Position` (or keeps both during migration) | | `PhysicsEngine.cs:606-625, :513-514, :341-342, :113-235` | terrain-sampling + `Resolve` loop read baked `WorldOffset` | read the cell frame via `GetBlockOffset`; no `WorldOffset` | | `PhysicsEngine.cs:858-861` | `return ... lbPrefix | (targetCellId & 0xFFFF)` | return the canonicalized `Position` | | `CellTransit.cs:732-736, :242, :845, :484` | world XY − `TryGetTerrainOrigin` (Zero fallback) → `GetOutsideLcoord` | run `AdjustToOutside` on the carried `(cell, local)`; **delete the `(0,0)` fallback + legacy-anchor assumption**; drop physics use of `TryGetTerrainOrigin` | | `TransitionTypes.cs ~:398-401, ~:2315` | `SetCheckPos` commits marched cell id without moving position | `curr_pos`/`check_pos` are `Position`; `validate_transition` lockstep adoption | | `LandDefs.cs` | `GetBlockOffset` MISSING | port it (verified §2) | | `GameWindow.cs:7753-7765` (outbound) | `localY = Position.Y − (lbY − _liveCenterY)·192` + `_liveCenter` fallback `:7755-7761` | outbound `(cell, local)` IS the carried `Position` — no subtraction, no `17410` | | `GameWindow.cs:4989-4995`, `RemoteMoveToDriver.cs:271-276` (inbound) | wire `(cell, local)` + `_liveCenter` origin → world | build a `Position` directly from wire `(cell, local)` | | `GameWindow.cs:5415-5416, :5441-5448` (teleport recenter, physics reads) | compute old LB from frozen pos; encode stale center | pass cell coords; `_liveCenter` recenter stays a RENDER decision only | | `PlayerMovementController.cs:803, :943-977` | `SetPosition` zeros velocity; next `Update()` re-applies stale run vector | idle the motion state on arrival (Slice 7) | `_liveCenter` RENDER reads (keep): the ~20 sites mapped in the frame-map output (origin calcs at `:2930-2931`, `:4992-4993`, `:5832`, `:5950`, `:6132`, `:6573`, observer centers `:7406-7472`, debug `:12746-12883`, `AddLandblock` bake `:6770-6771`, `ParticleEmitterRenderer`). ## 5. Conformance strategy — transform-on-read (guarded, no circular goldens) The committed world-space fixtures stay **byte-for-byte**: `CellarUpTrajectoryReplayTests`, `DoorBugTrajectoryReplayTests`, `CellarLipWedgeTests`, `DoorwayMembershipReplayTests`, `CameraCornerSealReplayTests`, `ThresholdPortalCrossingReplayTests`, `Issue98CellarUpReplayTests`, `Issue112MembershipTests`. The harness converts each captured `(world position + cell id) → Position` at load and asserts in the appropriate frame. **Goldens are unchanged → green proves no regression.** We do NOT re-capture (that would let the new code grade its own homework). **New test:** `TeleportFarTownRunawayTests` built from `desync-capture.jsonl` (runaway records `0xC95B0001`→`0xC9000008`; first far-town velocity `(3.637,−11.276,0)`). Assert: with `0xC95A` unregistered, the cell-relative resolve keeps `(cell, local)` consistent (no march). Include an **east-edge** spawn case — the cascade is direction-agnostic. ## 6. Slice plan (parallel-frame incremental) Each slice: `dotnet build` green + full Core / App / UI / Net suites green; divergence-register rows updated in the SAME commit; commit message names the slice + cites the decomp address per ported function. Each implementer follows the grep-named-first workflow (Step 0–1) and verifies their function against the decomp before porting. 1. **Types + `GetBlockOffset`.** Introduce `Position` / `Frame`; port `LandDefs.GetBlockOffset` (verified §2) with conformance tests (including same-landblock→Zero, neighbor→±192, map-edge, the `arg==0` degenerate cases). No behavior change — types unused. 2. **Body carries `Position`.** `PhysicsBody.Position : Position`; computed `WorldPosition`; run `AdjustToOutside` on every `SetPosition`/teleport. Render + wire read `WorldPosition`. Behavior unchanged (assert via the existing suites). 3. **Membership through the carried `Position`.** Route `CellTransit` pick + `AddAllOutsideCells` through `AdjustToOutside` on the `(cell, local)` pair; **delete the `(0,0)` physics fallback** + the legacy-anchor assumption; drop physics use of `TryGetTerrainOrigin`. **← closes the cascade.** Land `TeleportFarTownRunawayTests` here (must go green). 4. **Inter-tick collision state cell-relative.** Store contact plane + walkable poly in their cell's frame + cell id; translate via `GetBlockOffset` per tick (retail `0x0050A592`); `validate_transition` lockstep adoption; `curr_pos`/`check_pos` as `Position`. **← the B-specific work.** Hold every physics replay green. 5. **Wire off `Position`.** Outbound + inbound `(cell, local)` derive from the carried `Position`; remove `_liveCenter` from the wire path (kills the `17410` leak at the source). 6. **`_liveCenter` render-only.** Delete physics reads of `_liveCenter` + baked `WorldOffset` (`PhysicsEngine` terrain-sampling + `Resolve` loop switch to the cell frame). Retire register row AP-36 (streaming-center physics gate) + the wire-leak row. 7. **(separable) Teleport velocity-idle.** Idle the motion state on arrival (`DoMotion(Ready,1.0f)` / `StopCompletely()` after `PlaceTeleportArrival`) so the player doesn't resume the pre-teleport run. Not a cascade fix anymore — the last user-visible bit of the #145 repro. **Scope:** the OUTDOOR frame is the target (where the leak lives). Indoor cell tracking is already cell-keyed and its behavior is UNCHANGED — the `Position` type only unifies representation. Map-edge towns resolve via the absolute lcoord grid (Slice 1 conformance). ## 7. Verification items the implementer MUST confirm against the decomp (do NOT guess) - `validate_transition` (`0x0050AA70`): exact lockstep ordering + what else it caches (`cache_global_curr_center`). - `cache_global_curr_center` (`0x0050C740`): `m_fl2gv` matrix row/column order vs acdream's `Quaternion` convention (transposition risk — the oracle flagged this). - contact-plane translation sign (`0x0050A592`): direction of the `GetBlockOffset` add relative to the plane. - `Position::adjust_to_outside` (`0x00504A40`): the by-ref cell-id output signature. - Map-edge behavior: a destination whose needed neighbor genuinely doesn't exist resolves sanely (retail clamps via `get_outside_lcoord` / absolute lcoord grid) — confirm the acdream port matches, no march, no throw. ## 8. Divergence-register bookkeeping - Slices retiring the streaming-relative physics frame **delete** the relevant rows in the same commit: AP-36 (streaming-center gate) and the wire-leak row. Re-point AP-48 (#138 re-hydrate) if touched. - Any NEW deviation introduced (e.g. a temporary dual-frame accessor that outlives the migration) gets a row in the same commit, retired when the migration completes. ## 9. Pointers - Handoff (full verified mechanism + apparatus + retail port table): `docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md` - Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` - Decomp oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 - Wire cross-check: `references/holtburger` (client frame) + `references/ACE` (server validation) - Apparatus: `desync-capture.jsonl` (runaway) + `PhysicsResolveCapture` + the `CellarUp` `LiveCompare_*` replay template - Frame map (this session's exploration): the `map-physics-frame-145` workflow output