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>
16 KiB
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 yet → TryGetTerrainOrigin 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: (dX−sX)·24, (dY−sY)·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(theforeach(_landblocks)outdoor membership scan),:624,:858-860(returnslbPrefix | (targetCellId & 0xFFFF)). - Baked per-landblock WorldOffset computed against the MOVING center at load:
GameWindow.csorigin computation~:4992(and the WorldOffset inverse used outbound at~:2930-2931/~:4992-4993— note the synthesis cited:7763-7765for the outboundlocalYconversion but a verifier could not locate that exact line; re-locate the outbound (cell, local) builder — it's wherelocalY = Position.Y − (lbY − _liveCenterY)·192happens). - The
(0,0)fallback (proximate trigger):CellTransit.cs:736(discarded bool) → consumed at the seedAddAllOutsideCells(:758→:242), the containing-pick (:845), and the other seed (:484).CellGraph.cs:47-56(TryGetTerrainOriginreturns(Zero,false)for unregistered terrain). - The teleport that leaves running velocity live:
src/AcDream.App/Input/PlayerMovementController.cs(NOTE: App/Input, not Core/Physics) —SetPositionzeros velocity~:803but the same-frame motion path re-applies the running vector~:943-980. A teleport arrival should call the motion-idle path. TransitionTypes.cs ~:2315(SetCheckPoscommits 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
AdjustToOutsiderun on the per-frame path; (4) flip the player body to cell-relative; (5) make the outbound wire derive from it; (6) make_liveCenterrender-only; (7) delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existingCellarUp/DoorBug/membership harnesses must stay green.
Apparatus (use this — do NOT rebuild)
- The captured runaway:
desync-capture.jsonl(worktree root, 72,401ResolveWithTransitionrecords:input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId},bodyBefore/After{position,orientation,velocity},result). Runaway records: cellId0xC95B0001→0xC9000008(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.csfrom the capture (apparatus =PhysicsResolveCapture+ theCellarUpTrajectoryReplayTestsLiveCompare_*pattern). Assert: with0xC95Aunregistered, 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.csis undersrc/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_lcoordclamps; 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-7765was 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).