acdream/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md
Erik 67f98e8e72 docs(#145): verified root cause + decision to port cell-relative physics frame (Option B) + handoff
Workflow wf_87607d15-c43 (4 research streams + synthesis + 3 adversarial verifiers, HIGH confidence) confirmed the far-town runaway is a cell-membership label cascade from a discarded TryGetTerrainOrigin bool (CellTransit.cs:736 -> (0,0) origin for unstreamed neighbors), not a free-fall; 17410 is a wire artifact. User chose the architectural fix: port retail's cell-relative Position + retire _liveCenter from physics. Handoff doc carries the verified mechanism, the retail port table (decomp addresses), acdream divergence sites, apparatus (desync-capture.jsonl + probes + harness template), and the brainstorming-gate requirement.

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

16 KiB
Raw Blame History

Handoff — #145-residual: port the physics frame to CELL-RELATIVE (retail-faithful), retire the streaming center from physics

Date: 2026-06-21 Branch: claude/thirsty-goldberg-51bb9b — committed, NOT merged to main (user's call: "we can merge later") Milestone: M1.5 "Indoor world feels right" (this is the last blocker for far-town/dungeon round-trips) HEAD at handoff: a4f0b51 (the #145 reopen) + an uncommitted ISSUES.md #145 root-cause update (committed alongside this doc). DECISION (user, 2026-06-21): Go with Option B — the architectural fix. Make the physics position cell-relative like retail and retire the streaming _liveCenterX/_liveCenterY from the physics path (render-only). The user explicitly rejected the targeted patch (Option A) in favor of "done right, never again." Quote: "It should be agnostic." Start with a brainstorming gate (this is a multi-commit physics-frame phase touching the physics↔streaming seam broadly).


What shipped this session (context — already committed)

Commit What
bf66fb4 #138 FIXED + user-verified — server objects re-hydrate from the retained spawn table (_lastSpawnByGuid) on landblock reload; ACE won't re-send known objects, so we re-project. Register AP-48.
0a5f91b #138 secondary — rescue persistent entities from the pending bucket on unload (player-vanish candidate).
aa4a04d/b07825c #138 docs + handoff correction (the prior handoff's ClientObjectTable advice was WRONG — it's inventory-only; _lastSpawnByGuid is the world-object table).
a4f0b51 #145 REOPENED — far-town teleport resolver runaway (residual of the 2026-06-20 source-drop fix).

#138 is DONE and independent of this work — the re-hydrate (objects come back) is user-confirmed and the capture proved it is NOT involved in the runaway. It's mergeable as-is.


THE BUG (verified — multi-agent workflow wf_87607d15-c43 + 3 adversarial verifiers, all holdsUp=true, confidence HIGH)

Full report: …/tasks/w8wm5fln4.output (synthesis + 3 verdicts) and the workflow transcript dir. Distilled:

It is a cell-membership LABEL cascade, NOT a real free-fall. Teleport to a FAR town (e.g. (201,91)=0xC95B) places the player CORRECTLY at 0xC95B0001 local (14.8, 0.3, 12) (the #145 verbatim path). Then the per-frame resolve marches the cell id one landblock south per physics quantum (~33 ms) while the physics body barely moves (capture: max|Y|≈86 m, Z falls to ≈135 then walk-back; settles at the TRUE rest (-6.124, -30.186, 12.000), og=True). The cell-id lbY byte counts 0x5B(91)→0x00. The 17410 ACE rejects is a wire-conversion artifact — once lbY marched to 0, the outbound localY = Position.Y (lbY _liveCenterY)·192 back-adds 91×192 = 17472. The body never went there.

Proximate cause (single source line): src/AcDream.Core/Physics/CellTransit.cs:736

cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);   // bool DISCARDED

For a far town the player is placed at the southern landblock edge (world-Y≈0.3) with stale southward running velocity (the teleport never idled the motion state). The first tick crosses Y=0 into landblock 0xC95A, which has not streamed in yetTryGetTerrainOrigin returns false with origin=Vector3.Zero (CellGraph.cs:47-56; the comment at CellTransit.cs:732-735 literally self-documents this "legacy anchor-frame assumption (world frame == block-local frame)"). (0,0) flows into AddAllOutsideCells (:758) → GetOutsideLcoord (LandDefs.cs:110-111) does ly += floor(worldY/24) = floor(-0.088/24) = -1 → cell marches one block south. The CORRECT origin for 0xC95A is (0,-192), which would give floor(191.9/24)=+7 (right region). The missing 192 rebase is the bug. Every next neighbor is also unstreamed → the (0,0) cascade self-perpetuates 91×.

Why FAR-TOWN only: Holtburg is the streaming startup center only because +Je spawns there (the center recenters to the login landblock at GameWindow ~:2879 and on every teleport at ~:5447 — it is NOT hardcoded to Holtburg). Holtburg works because its neighbors are already streamed (registered), so the (0,0) fallback never fires, and its spawns are mid-block (no Y=0 crossing to arm the cascade). The bug fires at the EDGE of the streamed region for ANY fresh teleport. This is the non-agnostic assumption the user flagged: (0,0) = "this landblock is home" — only true at the center.

#138 re-hydrate EXONERATED: it fired only for Holtburg + the dungeon (log lines 412/498/710), never for the far town during the runaway (746+); it adds only RENDER entities, never the physics _landblocks the resolver iterates.


THE FIX — Option B: cell-relative physics Position (port retail)

The retail design (decomp-verified, addresses re-confirmed against live Ghidra patchmem :8081): retail stores EVERY physics position cell-relative and re-bases the stored origin in place on every placement and every boundary crossing, so the (cell, local) pair can never drift apart. There is no streaming "center" anywhere in retail's physics. Make acdream's physics match this; _liveCenterX/Y becomes a render-only concern (the (lb _liveCenter)·192 math is correct for placing meshes around the camera, but must NOT be the physics authority).

Retail functions to port (named decomp + Ghidra — verified addresses)

Retail symbol Address Role
struct Position { uint objcell_id; Frame frame } acclient.h:30659 The position IS the (cell, local) pair. Frame.m_fOrigin (acclient.h:30654) is LOCAL to the cell, bounded [0,192) for outdoor. No global/world position field exists.
Position::adjust_to_outside 0x00504A40 Rebases the ACTUAL this->frame.m_fOrigin + this->objcell_id in place (not a temp).
LandDefs::adjust_to_outside 0x005A9BC0 Recomputes global landcell via get_outside_lcoord, rebuilds cell id via lcoord_to_gid, wraps origin into [0,192) (origin -= floor(origin/192)*192). The canonicalizer.
LandDefs::get_outside_lcoord 0x005A9B00 gX = blockLcoordX + floor(localX/24). (acdream LandDefs.cs:105-112 is a faithful port — but the per-frame pick feeds it a RAW world-frame Y via the (0,0) fallback instead of a wrapped local.)
LandDefs::get_block_offset 0x0043E630 The ONLY cross-cell translation: (dXsX)·24, (dYsY)·24 from the two cell ids' landblock bytes; returns ZeroVector when both cells share a landblock. A delta of two NAMED cells — never an accumulation vs a moving center.
LandDefs::blockid_to_lcoord / lcoord_to_gid 0x0043D680 / 0x004A19A0 Absolute global landcell grid (lcoord 0..0x7f8). No _liveCenter subtraction anywhere — the grid origin is the world origin, fixed.
CTransition::validate_transition 0x0050AA70 On each accepted step adopts curr_pos.objcell_id = check_pos.objcell_id AND curr_pos.frame = check_pos.frame together (lockstep), then cache_global_curr_center. The local frame is re-expressed into the destination cell during the sweep.
CPhysicsObj::AdjustPosition / SetPositionInternal 0x00511D80 / 0x00515BD0 Every placement/teleport runs adjust_to_outside → an inconsistent (lbY=0, localY=17410) pair cannot persist.
SPHEREPATH::cache_global_curr_center 0x0050C740 Builds the transient working "global" sphere center from frame.m_fl2gv·local + frame.m_fOrigin — cell-local only, NO landblock-world offset, rebuilt from curr_pos each step.

acdream already ports AdjustToOutside/GetOutsideLcoord/LcoordToGid/BlockidToLcoord in src/AcDream.Core/Physics/LandDefs.cs (from the #106 work) — WITH the [0,192) wrap (LandDefs.cs:130-145). The gap is that the per-frame outdoor resolve (CellTransit.BuildCellSetAndPickContaining) does NOT run the wrap+rebase; it re-derives lbPrefix from a world-XY scan against a (0,0)-or-baked-offset origin.

acdream divergence sites (the streaming-relative physics frame — what to change)

  • The frame itself: physics position is a streaming-relative world Vector3 (worldXY = (lb _liveCenter)·192 + local). Sites: PhysicsEngine.cs:603-614 (the foreach(_landblocks) outdoor membership scan), :624, :858-860 (returns lbPrefix | (targetCellId & 0xFFFF)).
  • Baked per-landblock WorldOffset computed against the MOVING center at load: GameWindow.cs origin computation ~:4992 (and the WorldOffset inverse used outbound at ~:2930-2931 / ~:4992-4993 — note the synthesis cited :7763-7765 for the outbound localY conversion but a verifier could not locate that exact line; re-locate the outbound (cell, local) builder — it's where localY = Position.Y (lbY _liveCenterY)·192 happens).
  • The (0,0) fallback (proximate trigger): CellTransit.cs:736 (discarded bool) → consumed at the seed AddAllOutsideCells (:758:242), the containing-pick (:845), and the other seed (:484). CellGraph.cs:47-56 (TryGetTerrainOrigin returns (Zero,false) for unregistered terrain).
  • The teleport that leaves running velocity live: src/AcDream.App/Input/PlayerMovementController.cs (NOTE: App/Input, not Core/Physics) — SetPosition zeros velocity ~:803 but the same-frame motion path re-applies the running vector ~:943-980. A teleport arrival should call the motion-idle path.
  • TransitionTypes.cs ~:2315 (SetCheckPos commits the marched cell id without moving position).
  • The recenter: GameWindow.OnLivePositionUpdated ~:5447 (drop-source + _liveCenterX/Y=lbX/lbY) and the login recenter ~:2879-2909. These STAY (render frame), but physics must stop depending on them.

Shape of the port (brainstorm this — do not just start coding)

The end state: a physics Position { uint ObjCellId; Frame{ Vector3 LocalOrigin∈[0,192) for outdoor; Quaternion } }, carried by the player/physics body instead of a streaming-relative world Vector3. Cross-cell offsets come ONLY from get_block_offset(cellA, cellB) (port 0x0043E630). Every SetPosition/teleport/cross-landblock move runs AdjustToOutside to re-wrap. _liveCenterX/Y is used by the RENDERER (and the outbound wire conversion derives the wire (cell, local) directly from the cell-relative Position — no center math needed, which kills the 17410 leak at the source). The streaming WorldOffset baked per-landblock can stay for the RENDER mesh placement but the physics resolve must not read it.

This is a large, seam-crossing change. It touches PhysicsEngine, CellTransit, TransitionTypes, CellGraph, the player controller, the outbound wire builder, and every test that constructs a physics position. Scope it via superpowers:brainstorming FIRST, then write a plan, then execute in reviewable slices (likely: (1) introduce cell-relative Position alongside the world frame; (2) port get_block_offset

  • route the resolve through it; (3) make AdjustToOutside run on the per-frame path; (4) flip the player body to cell-relative; (5) make the outbound wire derive from it; (6) make _liveCenter render-only; (7) delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existing CellarUp/DoorBug/membership harnesses must stay green.

Apparatus (use this — do NOT rebuild)

  • The captured runaway: desync-capture.jsonl (worktree root, 72,401 ResolveWithTransition records: input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId}, bodyBefore/After{position,orientation,velocity}, result). Runaway records: cellId 0xC95B00010xC9000008 (decimal 3372220424). Velocity at first far-town record = (3.637,11.276,0) (the stale run).
  • Probes (env vars, no rebuild): ACDREAM_PROBE_CELL=1[cell-transit] old->new pos=(x,y,z) reason=; ACDREAM_CAPTURE_RESOLVE=<path> → the JSON capture; ACDREAM_PROBE_RESOLVE=1 → per-resolve [resolve] lines; ACDREAM_DUMP_LIVE_SPAWNS=1 → spawn + re-hydrate + recenter lines.
  • The session log of the repro: …/tasks/bu932mwj3.output (cell-transit trail + [snap] + teleport arrivals + the healthy Holtburg cell-transits for comparison).
  • Test harness template: build tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs from the capture (apparatus = PhysicsResolveCapture + the CellarUpTrajectoryReplayTests LiveCompare_* pattern). Assert: with 0xC95A unregistered, the cell-relative resolve keeps cell+local consistent (no march). Include a non-south case (east-edge spawn) — the cascade is direction-agnostic.

Open items / risks / DO-NOT-RETRY

  • Verifier corrections to the synthesis prose (use the corrected facts): march rate is ~1 landblock per physics QUANTUM (~33 ms), not per render frame (~40 capture records per crossing); max|Position.Y|≈85.67 (not 61.9); the true settle is (-6.124,-30.186,12.000) og=True (not (21.87,-21.62,-6.50) — that's a mid-fall entry); PlayerMovementController.cs is under src/AcDream.App/Input/. The MECHANISM is unchanged.
  • DO-NOT-RETRY: the physics digest DO-NOT-RETRY table has NO conflict (it covers cellar-up/door/lip wedges — different code path). But do NOT attempt a membership "stickiness"/hysteresis fix — the table explicitly disproves stickiness for the lip path; the correct shape is cell-relative storage, not hysteresis. Also the digest already names this seam: TryGetTerrainOrigin "Zero fallback = legacy anchor-frame" + the feedback_latent_bug_masked_by_fallback lesson ("a fallback masked a production bug") — this is that pattern recurring. Read the physics digest first.
  • Map-edge towns: confirm a destination at the world edge (a needed neighbor landblock genuinely doesn't exist) resolves sanely under the cell-relative frame (retail handles this via the absolute lcoord grid — get_outside_lcoord clamps; verify the acdream port matches).
  • Outbound wire: after the port, the wire (cell, local) must derive from the cell-relative Position directly. Re-locate the current outbound builder (the synthesis's :7763-7765 was unverified).

Launch / probes / accounts (unchanged)

$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!'   # single-quote the '!'
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_CAPTURE_RESOLVE = "<worktree>\desync-capture2.jsonl"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log

Account notan/MittSnus81!+Je (guid 0x50000001), spawns Holtburg, reaches the 0x0007 Town Network hub → portals to far towns (the repro: hub → any far town → the runaway arms when you arrive still running). The user drives the client lifecycle — do NOT kill/close clients; dotnet run when asked, read logs. Visual confirmation requires the user (the one genuine stop point).

Pointers

  • ISSUES #145 (reopened — full verified root cause + the A-vs-B decision), #138 (DONE).
  • Physics digest (SSOT + DO-NOT-RETRY): claude-memory/project_physics_collision_digest.md — read FIRST.
  • Workflow output (full synthesis + 3 verdicts): …/tasks/w8wm5fln4.output; transcripts under …/subagents/workflows/wf_87607d15-c43/ (the retail-oracle agent's report is the decomp goldmine).
  • Register: AP-36 (the streaming-center gate) + AP-48 (#138 re-hydrate) — the B port will retire/rewrite the physics half of the streaming-relative-frame divergence; add/update register rows in the porting commits.
  • References: named-retail/acclient_2013_pseudo_c.txt + acclient.h + Ghidra patchmem :8081 (the oracle); references/ACE + references/holtburger (the wire/client frame cross-check).