From fd1f86b771e8a444e1993b6572130f2bc37f287d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 10:18:18 +0200 Subject: [PATCH] =?UTF-8?q?docs(#145):=20Option=20B=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20cell-relative=20physics=20Position=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved design for the cell-relative physics frame port: retire _liveCenter from physics (render-only), carry Position{ObjCellId, Frame{local in [0,192), quat}} as the source of truth, port get_block_offset (verified 0x0043e630 nets to delta-landblock * 192 m), translate inter-tick collision state per-cell like retail (0x0050a592). 7-slice parallel-frame migration; conformance via transform-on-read (no fixture re-capture). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-145-cell-relative-physics-frame-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md diff --git a/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md b/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md new file mode 100644 index 00000000..02c7e767 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md @@ -0,0 +1,188 @@ +# 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