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>
15 KiB
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)
// 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
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.
- Types +
GetBlockOffset. IntroducePosition/Frame; portLandDefs.GetBlockOffset(verified §2) with conformance tests (including same-landblock→Zero, neighbor→±192, map-edge, thearg==0degenerate cases). No behavior change — types unused. - Body carries
Position.PhysicsBody.Position : Position; computedWorldPosition; runAdjustToOutsideon everySetPosition/teleport. Render + wire readWorldPosition. Behavior unchanged (assert via the existing suites). - Membership through the carried
Position. RouteCellTransitpick +AddAllOutsideCellsthroughAdjustToOutsideon the(cell, local)pair; delete the(0,0)physics fallback + the legacy-anchor assumption; drop physics use ofTryGetTerrainOrigin. ← closes the cascade. LandTeleportFarTownRunawayTestshere (must go green). - Inter-tick collision state cell-relative. Store contact plane + walkable poly in their cell's frame +
cell id; translate via
GetBlockOffsetper tick (retail0x0050A592);validate_transitionlockstep adoption;curr_pos/check_posasPosition. ← the B-specific work. Hold every physics replay green. - Wire off
Position. Outbound + inbound(cell, local)derive from the carriedPosition; remove_liveCenterfrom the wire path (kills the17410leak at the source). _liveCenterrender-only. Delete physics reads of_liveCenter+ bakedWorldOffset(PhysicsEngineterrain-sampling +Resolveloop switch to the cell frame). Retire register row AP-36 (streaming-center physics gate) + the wire-leak row.- (separable) Teleport velocity-idle. Idle the motion state on arrival
(
DoMotion(Ready,1.0f)/StopCompletely()afterPlaceTeleportArrival) 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_fl2gvmatrix row/column order vs acdream'sQuaternionconvention (transposition risk — the oracle flagged this).- contact-plane translation sign (
0x0050A592): direction of theGetBlockOffsetadd 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+ theCellarUpLiveCompare_*replay template - Frame map (this session's exploration): the
map-physics-frame-145workflow output