acdream/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md
Erik c980763322 refactor(physics #145): Slice 1 — rename Frame->CellFrame to avoid DatReaderWriter.Types.Frame collision
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.

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

191 lines
15 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.

# 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
// Type named CellFrame (retail "Frame") to avoid colliding with
// DatReaderWriter.Types.Frame, which is used in physics-adjacent code.
public readonly record struct CellFrame(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, CellFrame Frame); // member keeps retail's name
```
One `Position` type covers indoor and outdoor (retail uniformity). `PhysicsBody.Position` becomes a
`Position` and is **the source of truth.** (The value type is `CellFrame`; the `Position.Frame` member
keeps retail's name. `Frame` collided with `DatReaderWriter.Types.Frame` — renamed during Slice 1.)
### 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 01) 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