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

15 KiB
Raw Blame History

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))·24nets 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)

// 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)`
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 0xC95B00010xC9000008; 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