From 67f98e8e7296c494569539a2a2fe78f2529839ab Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:47:01 +0200 Subject: [PATCH] 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) --- docs/ISSUES.md | 51 ++++- ...145-cell-relative-physics-frame-handoff.md | 198 ++++++++++++++++++ 2 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 52320d4e..19574edd 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -71,11 +71,52 @@ Holtburg↔dungeon works because Holtburg is the startup center; the far town ex 412/498/710); it did NOT fire for the far town during the runaway (746+) — and it only adds RENDER entities, never the physics `_landblocks` the resolver iterates. **Apparatus:** `[cell-transit]` trail (`ACDREAM_PROBE_CELL=1`) + `desync-capture.jsonl` (72,401 -`ResolveWithTransition` frames, `ACDREAM_CAPTURE_RESOLVE`). Suspected: the per-frame outdoor resolve -(`PhysicsEngine.Resolve` / `AdjustPosition` iterating `_landblocks`) does not normalize the local -position across a landblock crossing the way retail's `set_position`/`change_cell`/`AdjustPosition` -does — port that. Research workflow running (acdream resolver code + retail named-decomp/Ghidra -oracle + capture characterization → synthesis → adversarial verify). +`ResolveWithTransition` frames, `ACDREAM_CAPTURE_RESOLVE`). **ROOT CAUSE — VERIFIED 2026-06-21 (multi-agent workflow + adversarial verification, decomp-confirmed against live Ghidra):** +It is a **cell-membership cascade**, NOT a real free-fall. The physics body stays small + correct +(capture: max|Y|≈86 m, settles at the true rest `(-6.1,-30.2,12.0)`); only the **cell id** marches +one landblock south per physics quantum (~33 ms) until the landblock-Y byte underflows to 0x00. The +`17410` is a **wire-conversion artifact** (the outbound `localY = Position.Y − (lbY−_liveCenterY)·192` +adds back 91×192 once `lbY` marched to 0), never a physics value. +**The bug (single source line):** `CellTransit.cs:736` — +`cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin)` **discards the bool**. +For a far town, the player is placed at the southern landblock edge (world-Y≈0.3) with **stale +southward running velocity** (teleport doesn't idle 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` (the comment at `CellTransit.cs:732-735` self-documents this "legacy +anchor-frame" fallback). `(0,0)` is handed to `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 `(0,-192)` would give `floor(191.9/24)=+7` (right region) — the +missing −192 rebase IS the bug. Every subsequent neighbor is also unstreamed → the cascade +self-perpetuates 91 times. **Far-town-only** because Holtburg (the streaming startup center) has all +neighbors permanently registered, so the fallback never fires + spawns are mid-block (no Y=0 cross). +**Retail contrast (decomp-confirmed, addresses re-verified in Ghidra patchmem):** retail stores +position CELL-RELATIVE (`Position{ uint objcell_id; Frame{ m_fOrigin ∈ [0,192) } }`, `acclient.h:30659`) +and rebases the ACTUAL stored origin in place on every placement via `Position::adjust_to_outside` +(0x00504A40) → `LandDefs::adjust_to_outside` (0x005A9BC0, wraps origin into [0,192) + recomputes the +cell id) called from `AdjustPosition` (0x00511D80) / `SetPositionInternal` (0x00515BD0); cross-cell +offsets come only from `get_block_offset` (0x0043E630, delta of two cell ids, zero within a +landblock). An inconsistent (cell, local) pair is structurally impossible in retail. acdream's +streaming-relative physics frame (`worldXY=(lb−_liveCenter)·192+local`, per-landblock baked +WorldOffset) is the deeper divergence; the proximate bug is the discarded-bool `(0,0)` fallback. + +**FIX DIRECTION (report-only — awaiting user approval):** +- **4a (targeted, faithful, this-session):** honor `TryGetTerrainOrigin`'s `false` at `CellTransit.cs:736` + — when the current landblock's terrain isn't resident, the outdoor pick has no trustworthy + block-local frame, so HOLD the current (cell, position) verbatim (no march), the same + "frame-not-yet-authoritative ⇒ return input" contract the NO-LANDBLOCK Resolve branch already uses. + PAIR with **idle the motion state on teleport arrival** (stop re-applying the dungeon running + velocity the same frame as `SetPosition`) so the body never attempts the first crossing before + terrain streams. Adaptation (not a direct retail port — retail gets it free via cell-relative + storage) ⇒ needs a divergence-register row. Deterministic replay test from the capture + (`TeleportFarTownRunawayTests`: with `0xC95A` unregistered, resolve returns `currentCellId` + unchanged). Consumer sites to cover via the early-return: `:758/242` (seed), `:845` (pick), `:484`. +- **4b (architectural, file as a phase, brainstorm-gated):** port retail's cell-relative physics + `Position` + make `_liveCenter` render-only + `get_block_offset` as the only cross-cell translation. + Removes the whole class structurally. Multi-commit. +**Open items:** map-edge wedge (verbatim-hold could hover the player at a boundary if the needed +neighbor never streams — likely mitigated by the motion-idle + the destination center always +streaming; verify). **Confidence: HIGH** (all 3 adversarial lenses holdsUp=true; capture- and +decomp-verified). Full report: workflow `wf_87607d15-c43`. **[ORIGINAL #145 — source-frame overlap, FIXED 2026-06-20; history below]** diff --git a/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md b/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md new file mode 100644 index 00000000..a0c6b543 --- /dev/null +++ b/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md @@ -0,0 +1,198 @@ +# 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` +```csharp +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` (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 `0xC95B0001`→`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=` → 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) + +```powershell +$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 = "\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).