diff --git a/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md b/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md new file mode 100644 index 00000000..cdd3cbe1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md @@ -0,0 +1,558 @@ +# Cell-Relative Physics Frame (#145 Option B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make acdream's physics position cell-relative like retail (`Position{ ObjCellId, Frame{ Origin∈[0,192), Quaternion } }`), retire the streaming center `_liveCenterX/Y` from the physics path (render-only), so the far-town teleport cell-label cascade (#145) becomes structurally impossible. + +**Architecture:** Parallel-frame incremental migration. Introduce the cell-relative `Position`/`Frame` types and the one missing primitive (`GetBlockOffset`) with zero behavior change, then migrate the physics body, membership pick, inter-tick collision state, and the wire onto the carried `Position` slice-by-slice — holding every existing conformance fixture green via transform-on-read — and finally delete `_liveCenter`/`WorldOffset` from the physics reads. Each slice ends green and commits. + +**Tech Stack:** C# .NET 10, `System.Numerics`, xUnit. Decomp oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` (grep by `class::method`); Ghidra patchmem :8081 for single-function lookups. + +**Design spec (read first):** `docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md` +**Handoff (verified mechanism + apparatus):** `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` + +--- + +## Methodology note for every task + +This is a faithful decomp port, not greenfield code. **Each task that ports retail behavior MUST follow the grep-named-first workflow:** read the cited decomp address(es) AND the existing acdream code listed under **Read first** BEFORE writing, then port line-by-line with the same variable names and boundary conditions. Do NOT "improve" the algorithm. Every ported function carries a comment citing its decomp address. Each deviation introduced/retired updates `docs/architecture/retail-divergence-register.md` in the same commit. + +**Green bar per slice** = `dotnet build` clean + these suites pass: +``` +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj +dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj +dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj +``` + +## File structure (what gets created / modified across all slices) + +| File | Responsibility | Slices | +|---|---|---| +| `src/AcDream.Core/Physics/Position.cs` (create) | `Frame` + `Position` value types | 1 | +| `src/AcDream.Core/Physics/LandDefs.cs` (modify) | add `GetBlockOffset` | 1 | +| `src/AcDream.Core/Physics/PhysicsBody.cs` (modify) | carry `Position`; computed `WorldPosition`; `AdjustToOutside` on placement; integrate-then-rewrap | 2,4 | +| `src/AcDream.Core/Physics/ResolveResult.cs` (modify) | carry the canonicalized `Position` | 2,3 | +| `src/AcDream.Core/Physics/CellTransit.cs` (modify) | membership pick via carried `Position` + `AdjustToOutside`; delete `(0,0)` fallback | 3 | +| `src/AcDream.Core/Physics/PhysicsEngine.cs` (modify) | resolve loop / terrain sampling off the cell frame | 3,6 | +| `src/AcDream.Core/Physics/TransitionTypes.cs` (modify) | `curr_pos`/`check_pos` as `Position`; contact-plane translate; `validate_transition` lockstep | 4 | +| `src/AcDream.App/Rendering/GameWindow.cs` (modify) | wire off `Position`; `_liveCenter` render-only | 5,6 | +| `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (modify) | inbound builds `Position` | 5 | +| `src/AcDream.App/Input/PlayerMovementController.cs` (modify) | teleport velocity-idle | 7 | +| `tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs` (create) | `GetBlockOffset` conformance | 1 | +| `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` (create) | the #145 regression pin | 3 | +| Existing replay/conformance tests (modify setup only) | transform-on-read seam | 2–4 | + +--- + +## Task 1: Cell-relative types + `GetBlockOffset` (zero behavior change) + +**Files:** +- Create: `src/AcDream.Core/Physics/Position.cs` +- Modify: `src/AcDream.Core/Physics/LandDefs.cs` (add method after `AdjustToOutside`, before the private `CellLowInRange`) +- Test: `tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs` (create) + +**Read first:** `src/AcDream.Core/Physics/LandDefs.cs` (whole file — note `CellLength=24f`, `BlockLength=192f`); decomp `LandDefs::get_block_offset` @0x0043e630 (pc:69189). + +- [ ] **Step 1: Create the value types** + +`src/AcDream.Core/Physics/Position.cs`: +```csharp +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Retail Frame (acclient.h:30647). is LOCAL to the +/// owning cell: outdoor → X/Y ∈ [0,192) within the landblock, Z = height; +/// indoor → the EnvCell-relative placement. replaces +/// retail's m_fl2gv 3×3 local→global matrix (equivalent rotation). +/// +public readonly record struct Frame(Vector3 Origin, Quaternion Orientation); + +/// +/// Retail Position (acclient.h:30658): the (which-cell, where-inside) pair. +/// One type for indoor and outdoor. The full 32-bit cell id (high 16 = landblock +/// prefix, low 16 = cell index) plus the cell-local . Neither +/// half is ever reconstructed from a streaming center — see #145 design spec. +/// +public readonly record struct Position(uint ObjCellId, Frame Frame) +{ + public Position(uint objCellId, Vector3 origin, Quaternion orientation) + : this(objCellId, new Frame(origin, orientation)) { } +} +``` + +- [ ] **Step 2: Write the failing `GetBlockOffset` test** + +`tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs`: +```csharp +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class LandDefsBlockOffsetTests +{ + // get_block_offset(src, dst) returns the world-meter offset from src's + // landblock origin to dst's landblock origin. block_byte<<3 = lcoord, *24 m/cell + // → nets to (Δlandblock)·192 m. Same landblock → Zero. Decomp @0x0043e630. + + [Fact] + public void SameLandblock_ReturnsZero() + { + // both 0xC95B + Assert.Equal(Vector3.Zero, LandDefs.GetBlockOffset(0xC95B0001u, 0xC95B0008u)); + } + + [Fact] + public void SouthNeighbour_IsMinus192Y() + { + // 0xC95B (lbY=0x5B) → 0xC95A (lbY=0x5A): one landblock south = (0,-192,0). + // This is the exact #145 case: correct origin for 0xC95A relative to 0xC95B. + Assert.Equal(new Vector3(0f, -192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xC95A0001u)); + } + + [Fact] + public void EastNeighbour_IsPlus192X() + { + // 0xC95B (lbX=0xC9) → 0xCA5B (lbX=0xCA): one landblock east = (+192,0,0). + Assert.Equal(new Vector3(192f, 0f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5B0001u)); + } + + [Fact] + public void DiagonalNeighbour_CombinesBothAxes() + { + // 0xC95B → 0xCA5C: lbX +1 (+192), lbY +1 (+192). + Assert.Equal(new Vector3(192f, 192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5C0001u)); + } +} +``` + +- [ ] **Step 3: Run it — expect compile failure (method missing)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LandDefsBlockOffsetTests` +Expected: build error — `'LandDefs' does not contain a definition for 'GetBlockOffset'`. + +- [ ] **Step 4: Port `GetBlockOffset` faithfully** + +In `src/AcDream.Core/Physics/LandDefs.cs`, add after `AdjustToOutside` (after line 142): +```csharp + /// + /// LandDefs::get_block_offset (pc:69189, @0x0043e630): world-meter offset + /// from 's landblock origin to 's. + /// ZeroVector when both ids share a landblock (high words equal). The decomp's + /// block_byte<<3 converts to lcoord (cell) units and the literal + /// ·24 m/cell then nets to (Δlandblock)·192 m per axis. The + /// degenerate arg==0 fallbacks are ported verbatim (dest==0 → raw source + /// id; source==0 → 0 lcoord). The ONLY cross-cell translation in retail physics: + /// a delta of two NAMED cell ids, never an accumulation against a moving center. + /// + public static Vector3 GetBlockOffset(uint source, uint dest) + { + uint srcBlock = source >> 16; + uint dstBlock = dest >> 16; + if (srcBlock == dstBlock) + return Vector3.Zero; + + int srcLx, srcLy; + if (source == 0u) { srcLx = 0; srcLy = 0; } + else { srcLx = (int)((source >> 21) & 0x7f8u); srcLy = (int)((srcBlock & 0xFFu) << 3); } + + int dstLx, dstLy; + if (dest == 0u) { dstLx = (int)source; dstLy = (int)source; } // retail degenerate guard + else { dstLx = (int)((dest >> 21) & 0x7f8u); dstLy = (int)((dstBlock & 0xFFu) << 3); } + + return new Vector3((dstLx - srcLx) * CellLength, (dstLy - srcLy) * CellLength, 0f); + } +``` + +- [ ] **Step 5: Run the test — expect PASS** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LandDefsBlockOffsetTests` +Expected: 4 passed. + +- [ ] **Step 6: Full Core suite green (types added, nothing consumes them yet)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: all pass (no regressions; `Position`/`Frame` are unused). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/Position.cs src/AcDream.Core/Physics/LandDefs.cs tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs +git commit -m "feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `PhysicsBody` carries `Position`; `WorldPosition` accessor; canonicalize on placement (behavior unchanged) + +**Goal:** Make the body's source of truth a cell-relative `Position`, with a computed `WorldPosition` so render + wire keep working unchanged. Run `AdjustToOutside` wherever a placement/teleport sets position, so the (cell, local) pair is always canonical. No membership/sweep behavior changes yet. + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs:95` (the `Position` field) +- Modify: `src/AcDream.Core/Physics/ResolveResult.cs:21-37` +- Test: `tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs` (create) + +**Read first:** `PhysicsBody.cs` (whole — note `Position` used by `UpdatePhysicsInternal:381`); `ResolveResult.cs`; every caller of `PhysicsBody.Position` (grep `\.Position` in `src/AcDream.Core/Physics` and `src/AcDream.App`); `LandDefs.AdjustToOutside`; decomp `Position::adjust_to_outside` @0x00504A40. + +**Design decision for this slice (keep the migration safe):** Add `Position CellPosition { get; private set; }` as the new source of truth, and KEEP `Vector3 Position` as a computed accessor over `CellPosition.Frame.Origin` expressed in the body's *current-landblock-anchored* world frame. Because the anchored frame for the body's OWN landblock places the body at `WorldFromAnchor(cell, origin)`, and existing callers feed/read the streaming-world frame, introduce a single conversion owned by the seam (see Step 1). The goal is: existing tests stay green because `Position` round-trips through `CellPosition` with no numeric change for the player's own landblock. + +- [ ] **Step 1: Write the failing round-trip + canonicalization test** + +`tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs`: +```csharp +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class PhysicsBodyPositionFrameTests +{ + [Fact] + public void SetCellPosition_RoundTrips_Local() + { + var body = new PhysicsBody(); + body.SetCellPosition(new Position(0xC95B0001u, new Vector3(14.8f, 0.3f, 12f), Quaternion.Identity)); + + Assert.Equal(0xC95B0001u, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(14.8f, 0.3f, 12f), body.CellPosition.Frame.Origin); + } + + [Fact] + public void SetCellPosition_OutOfBoundsLocal_CanonicalizesCellAndOrigin() + { + // Local Y just below 0 in 0xC95B must rebase one landblock south to 0xC95A + // with origin wrapped into [0,192) — NOT a (0,0)-anchored march. + var body = new PhysicsBody(); + body.SetCellPosition(new Position(0xC95B0001u, new Vector3(14.8f, -0.1f, 12f), Quaternion.Identity)); + + // 0xC95B lbY=0x5B → south neighbour 0xC95A lbY=0x5A; low word recomputed. + Assert.Equal(0xC95Au, body.CellPosition.ObjCellId >> 16 << 8 >> 8 | (body.CellPosition.ObjCellId & 0xFF00u)); // block byte check + Assert.InRange(body.CellPosition.Frame.Origin.Y, 0f, 192f); + } +} +``` +(Adjust the block-byte assertion to the project's preferred form after reading `LcoordToGid`; the load-bearing assertion is `Origin.Y ∈ [0,192)` and the landblock id moved to the south neighbour, never `(0,0)`.) + +- [ ] **Step 2: Run — expect failure (`SetCellPosition`/`CellPosition` missing)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter PhysicsBodyPositionFrameTests` +Expected: build error. + +- [ ] **Step 3: Add `CellPosition` + `SetCellPosition` to `PhysicsBody`** + +In `PhysicsBody.cs`, replace the `Position` auto-property (line 95) with a `CellPosition`-backed model: +```csharp + /// Cell-relative source of truth (retail Position). #145 Slice 2. + public Position CellPosition { get; private set; } + + /// + /// Place the body at a cell-relative position, canonicalizing via + /// AdjustToOutside (Position::adjust_to_outside @0x00504A40) so the (cell, local) + /// pair is always valid — outdoor origin wrapped into [0,192), cell id rebuilt + /// from the absolute lcoord grid. An inconsistent pair cannot persist. + /// + public void SetCellPosition(Position p) + { + uint cellId = p.ObjCellId; + Vector3 origin = p.Frame.Origin; + // Outdoor canonicalization only (low word < 0x100). Indoor frames are + // EnvCell-relative and already validated by the BSP path. + if ((cellId & 0xFFFFu) is >= 1u and < 0x100u) + LandDefs.AdjustToOutside(ref cellId, ref origin); + CellPosition = new Position(cellId, new Frame(origin, p.Frame.Orientation)); + } +``` +Keep a `Vector3 Position` accessor for back-compat during migration. Its getter/setter convert through the body's anchored world frame. **The exact conversion to use is the inverse pair already in production** — see the render seam at `GameWindow.cs:4991-4995` (`worldPos = local + (lb − _liveCenter)·192`). For Slice 2, keep `Position` numerically identical to today by routing through a `IWorldFrame` seam supplied by the owner (default: identity for unit tests). Document that `Position` is deprecated and physics reads migrate to `CellPosition` in Slices 3–6. + +> **Orchestrator note:** This back-compat `Position` accessor is the one place a deviation row is warranted (a temporary dual-frame bridge). Add a register row "temporary PhysicsBody.Position world-frame bridge (#145 migration)"; retire it in Slice 6. + +- [ ] **Step 4: Update `ResolveResult` to carry the `Position`** + +In `ResolveResult.cs`, add a `Position ResolvedPosition` field alongside the existing `Vector3 Position` + `uint CellId` (keep both during migration; the new field defaults so existing constructors compile): +```csharp + /// #145: the canonicalized cell-relative result. Source of truth from + /// Slice 3 onward; Position/CellId remain as the world-frame bridge until Slice 6. + Position ResolvedPosition = default +``` +(Append as the last optional parameter so the 3 existing call sites — `PhysicsEngine.cs:858-861`, `:1155-1166`, and any others grep finds — still compile.) + +- [ ] **Step 5: Run — expect PASS + no regressions** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: new tests pass; all existing physics replay/conformance tests still green (behavior unchanged — `Position` bridge round-trips). + +- [ ] **Step 6: App/UI/Net suites green** + +Run the App/UI/Net suites (see Methodology). Expected: green. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/PhysicsBody.cs src/AcDream.Core/Physics/ResolveResult.cs tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs docs/architecture/retail-divergence-register.md +git commit -m "feat(physics #145): Slice 2 — PhysicsBody carries cell-relative Position; canonicalize on placement + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Membership pick through the carried `Position` — delete the `(0,0)` fallback (closes the cascade) + +**Goal:** The outdoor membership pick stops deriving the landblock from `TryGetTerrainOrigin` (which falls back to `(0,0)` for unstreamed neighbours). It runs `AdjustToOutside` directly on the carried `(cell, local)` pair. This is the fix — the far-town cascade becomes impossible. + +**Files:** +- Modify: `src/AcDream.Core/Physics/CellTransit.cs:732-736` (the discarded-bool fallback), `:242` (`AddAllOutsideCells` block-origin subtraction), `:845-849` (containing pick), `:484` (the other seed) +- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs:603-625` (the resolve loop feeds the pick) +- Test: `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` (create) + +**Read first (REQUIRED — this is the fix site):** `CellTransit.cs` in full, focusing on `BuildCellSetAndPickContaining`, `AddAllOutsideCells`, the legacy-anchor comment `:732-735`; `CellGraph.cs:47-56` (`TryGetTerrainOrigin`); `LandDefs.GetOutsideLcoord`/`AdjustToOutside`; the design spec §3.3–§4; decomp `LandDefs::adjust_to_outside` @0x005A9BC0, `get_outside_lcoord` @0x005A9B00. The apparatus capture `desync-capture.jsonl` (worktree root) is the regression source. + +- [ ] **Step 1: Write the failing regression test from the capture** + +`tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` — load the far-town arrival record(s) from `desync-capture.jsonl` (cellId `0xC95B0001`, first far-town velocity `(3.637,−11.276,0)`), drive the membership pick with the SOUTH neighbour `0xC95A` UNREGISTERED, and assert the cell id stays consistent with the body (no per-quantum march toward `0x00`). Mirror the existing `CellarUpTrajectoryReplayTests` `LoadCapturedRecord`/`SimulateTicks` apparatus (read it first). Include a second case where the unregistered neighbour is to the EAST (the cascade is direction-agnostic — assert no march in X either). + +Concrete assertion shape: +```csharp +[Fact] +public void FarTownArrival_SouthNeighbourUnregistered_CellDoesNotMarch() +{ + var (body, startCell) = LoadFarTownArrival("desync-capture.jsonl"); // 0xC95B0001 + // 0xC95A NOT registered in the CellGraph/landblock set. + for (int tick = 0; tick < 40; tick++) + Step(body); // one physics quantum each + + // Body barely moved (|Δ| small); the landblock id must NOT have walked + // south by tens of blocks. Allow at most one legitimate crossing. + int lbY = (int)((body.CellPosition.ObjCellId >> 16) & 0xFFu); + Assert.InRange(lbY, 0x5A, 0x5B); // pre-fix this marched 0x5B→0x00 +} +``` + +- [ ] **Step 2: Run — expect FAIL (the march reproduces)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter TeleportFarTownRunawayTests` +Expected: FAIL — `lbY` marches below `0x5A` (reproduces #145). If it does not reproduce, the harness isn't faithfully feeding the unregistered-neighbour path — fix the harness before proceeding (do NOT weaken the assertion). + +- [ ] **Step 3: Route the pick through `AdjustToOutside`; delete the `(0,0)` fallback** + +In `CellTransit.cs`, replace the `world XY − TryGetTerrainOrigin(→Zero fallback)` derivation at the pick (`:732-736`, `:845`) and seeds (`:242`, `:484`) with: take the carried `(cellId, localOrigin∈[0,192))` and call `LandDefs.AdjustToOutside(ref cellId, ref localOrigin)` — which crosses landblocks via the absolute lcoord grid, needing NO terrain registration. Delete the discarded-bool line and the legacy-anchor comment. Remove the physics use of `CellGraph.TryGetTerrainOrigin` from these paths (leave it for any render-only caller). Port faithfully against `adjust_to_outside` @0x005A9BC0; keep the existing straddle-gate / `outdoorPickAllowed` logic intact (that's a separate, correct mechanism — #112). + +> **Orchestrator note:** This is the slice the CLAUDE.md warning targets ("don't integrate via a subagent without full context"). The implementer must have read all of `CellTransit.cs`. Review the diff against the existing pick logic before accepting — the straddle gate (`:863`) and indoor-primary branch must be untouched. + +- [ ] **Step 4: Run the new test — expect PASS** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter TeleportFarTownRunawayTests` +Expected: PASS — no march under the unregistered neighbour, both south and east cases. + +- [ ] **Step 5: Full physics conformance green (no regression at registered-neighbour seams)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: all green — especially `CellarUp*`, `DoorBug*`, `DoorwayMembership*`, `ThresholdPortalCrossing*`, `Issue106*`/`Issue112*` membership tests. These prove normal landblock crossings (registered neighbours) still cross correctly. + +- [ ] **Step 6: App/UI/Net green; update ISSUES + register** + +Move #145 toward closed in `docs/ISSUES.md` (note "membership cascade fixed; full close after Slices 5–7 land the wire + velocity-idle"). The `(0,0)` legacy-anchor fallback deletion may retire/rewrite a register row — check `docs/architecture/retail-divergence-register.md` for the streaming-anchor row and annotate. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/CellTransit.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs docs/ISSUES.md docs/architecture/retail-divergence-register.md +git commit -m "fix(physics #145): Slice 3 — membership pick via AdjustToOutside on carried Position; delete (0,0) fallback — closes the cascade + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Inter-tick collision state cell-relative + `validate_transition` lockstep (the B-specific work) + +**Goal:** Store the contact plane + walkable polygon in their owning cell's frame + cell id, translating into the per-tick working frame via `GetBlockOffset` exactly like retail (`0x0050A592`). Carry `curr_pos`/`check_pos` as `Position` in the sweep. `validate_transition` adopts cell id AND frame in lockstep. + +**Files:** +- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (the SPHEREPATH/CTransition sweep: `curr_pos`/`check_pos`, `SetCheckPos ~:398-401, ~:2315`, contact-plane usage, `validate_transition`) +- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs` (the contact-plane/walkable fields `:128-151` become cell-relative + their `…CellId`; `UpdatePhysicsInternal:381` integrate-then-`AdjustToOutside`) + +**Read first (REQUIRED):** `TransitionTypes.cs` in full; `PhysicsBody.cs:115-151` (contact-plane fields); decomp — `cache_global_curr_center` @0x0050C740, `validate_transition` @0x0050AA70, the contact-plane translate at @0x0050A592, `SPHEREPATH::get_curr_pos_check_pos_block_offset` @0x00509FD0. **Verify the matrix/quaternion order and the translate sign against the decomp (oracle flagged a transposition risk).** + +- [ ] **Step 1: Write a failing seam test** + +Build a cross-landblock contact case: the body in landblock A standing on a contact plane owned by neighbour landblock B (a seam). Assert the working-frame contact plane = stored-plane translated by `GetBlockOffset(workingCell, contactPlaneCellId)`, and that walking across the seam keeps the plane geometrically stationary (the player doesn't pop off the ground at the seam). Use the `CameraCornerSealReplayTests`/`CellarUp` apparatus form. (If no existing seam fixture fits, construct a synthetic two-landblock body state — document the construction.) + +- [ ] **Step 2: Run — expect FAIL (plane not translated, or world-frame stored)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter ` +Expected: FAIL. + +- [ ] **Step 3: Port the per-cell contact-plane model + lockstep adoption** + +- Store `ContactPlane`/`WalkablePlane`/`WalkableVertices` in their owning cell's frame; keep `ContactPlaneCellId` as the anchor. In the sweep, before using the contact plane, translate it into `check_pos`'s working frame via `GetBlockOffset(check_pos.ObjCellId, ContactPlaneCellId)` iff `ContactPlaneCellId != 0` (faithful to @0x0050A592). +- `curr_pos`/`check_pos` become `Position`. `SetCheckPos` updates `check_pos.Frame` together with its cell id (never the id alone). +- `validate_transition`: adopt `curr_pos.ObjCellId = check_pos.ObjCellId` AND `curr_pos.Frame = check_pos.Frame` together; then rebuild the working center (`cache_global_curr_center`: `Orientation·local + Origin`, verified order). +- In `UpdatePhysicsInternal`, after the `Position += Velocity*dt …` integration, run `AdjustToOutside` on the resulting `(cell, local)` so a tick that crosses a landblock re-wraps immediately. + +> **Orchestrator note:** Largest, most delicate slice. Review the diff against `TransitionTypes.cs` carefully; the contact-plane retention semantics (digest: "seed CollisionInfo.ContactPlane from these fields") must be preserved — only the FRAME they live in changes. Hold every physics replay green before accepting. + +- [ ] **Step 4: Run the seam test — expect PASS** + +Run as Step 2. Expected: PASS. + +- [ ] **Step 5: Full physics conformance green (transform-on-read fixtures)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` +Expected: all green. The committed world-space fixtures stay byte-for-byte; the harness converts captured `(world plane + cell id) → cell-frame` on read. If a fixture goes red, the conversion seam is wrong — fix the seam, NEVER re-capture. + +- [ ] **Step 6: App/UI/Net green; register row** + +Add/update the register row for the per-cell contact-plane frame (retail-faithful port — if it retires the world-frame contact-plane divergence row, delete it). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/TransitionTypes.cs src/AcDream.Core/Physics/PhysicsBody.cs tests/AcDream.Core.Tests/Physics/ docs/architecture/retail-divergence-register.md +git commit -m "feat(physics #145): Slice 4 — inter-tick collision state cell-relative + validate_transition lockstep (0x0050aa70/0x0050a592) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Wire derives from the carried `Position` (kills the 17410 leak at the source) + +**Goal:** Outbound + inbound motion `(cell, local)` derive directly from the carried `Position`. The `localY = Position.Y − (lbY − _liveCenterY)·192` subtraction and its `_liveCenter` fallback are deleted, so the bogus `17410` can no longer be synthesized. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7753-7765` (outbound), `:4989-4995` (inbound) +- Modify: `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:271-276` (remote inbound) +- Test: `tests/AcDream.App.Tests/...` (create a wire round-trip test if an App-layer harness exists; otherwise a Core-level `Position`→wire→`Position` round-trip) + +**Read first:** `GameWindow.cs:7740-7840` (the outbound builder + the `:7755-7761` fallback), `:4971-4995` (inbound); `RemoteMoveToDriver.cs:260-280`; holtburger `client/movement/actions.rs` (MoveToState/AutonomousPosition wire field order) + `references/ACE` movement validation; design spec §4. + +- [ ] **Step 1: Write the failing round-trip test** + +Assert: given a carried `Position{ ObjCellId=0xC95A0001, Origin=(14.8, 191.9, 12) }`, the outbound wire `(cellId, localX, localY, localZ)` equals `(0xC95A0001, 14.8, 191.9, 12)` exactly — NOT `191.9 + 91·192 = 17463`. And inbound `(cellId, local)` rebuilds the same `Position`. (Pick values near a landblock edge so a `_liveCenter`-based formula would visibly diverge.) + +- [ ] **Step 2: Run — expect FAIL (old formula injects the offset)** + +Expected: FAIL — outbound localY shows the `(lbY − _liveCenterY)·192` addition. + +- [ ] **Step 3: Derive the wire from `Position`** + +Replace `:7753-7765` with: `wireCellId = body.CellPosition.ObjCellId; wirePos = body.CellPosition.Frame.Origin;` — no `lbX/lbY`/`_liveCenter` math, delete the `:7755-7761` fallback entirely. Inbound (`:4989-4995`, `RemoteMoveToDriver:271-276`): build `new Position(p.LandblockId/cellId, new Vector3(p.PositionX, p.PositionY, p.PositionZ), orientation)` directly; the render path converts to world for meshes via the seam (`WorldFromPosition`). + +- [ ] **Step 4: Run — expect PASS** + +Expected: PASS — wire local matches the carried origin exactly; round-trip identity. + +- [ ] **Step 5: Suites green; live cross-check note** + +Run all four suites. Expected: green. (Live ACE verification of an actual far-town portal is a user gate, recorded in Slice 7's handoff — the wire format is cross-checked vs holtburger here.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.Core/Physics/RemoteMoveToDriver.cs tests/ +git commit -m "fix(physics #145): Slice 5 — outbound/inbound wire derives from carried Position; delete _liveCenter wire math (17410 leak) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: `_liveCenter` + baked `WorldOffset` made render-only + +**Goal:** Delete every PHYSICS read of `_liveCenter`/`WorldOffset`. Physics is frame-free below the render seam. Retire the temporary `PhysicsBody.Position` world-frame bridge from Slice 2. + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (terrain sampling `:113-235`, `:341-342`, `:513-514`, the `Resolve` loop `:606-625`, `:858-861`) +- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs` (remove the deprecated `Position` bridge accessor; physics reads `CellPosition`) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5415-5448` (the teleport-recenter PHYSICS reads → render-only) + +**Read first:** the `_liveCenter`/`WorldOffset` classification table in the frame-map output (the ~20 render sites to LEAVE vs the physics sites to delete); `PhysicsEngine.cs` (`WorldOffsetX/Y` record `:59-60` + every consumer); design spec §4 "render reads (keep)". + +- [ ] **Step 1: Write the failing assertion that physics no longer reads `_liveCenter`/`WorldOffset`** + +A guard test: terrain sampling + `Resolve` produce identical results regardless of the streaming center value (drive the same cell-relative input with two different `_liveCenter` values; assert identical physics output). Pre-fix this fails (output depends on `WorldOffset`). + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Switch physics reads to the cell frame** + +Replace `world − WorldOffset` conversions in `PhysicsEngine` terrain sampling + `Resolve` with cell-frame access derived from the cell id via `GetBlockOffset` / the carried `Position`. Remove `WorldOffsetX/Y` from the physics read path (keep it only if a render caller still needs the baked value — verify none do). Delete the `PhysicsBody.Position` bridge. Make `GameWindow:5415-5448` teleport-recenter touch `_liveCenter` as a RENDER decision only (physics arrival uses the cell-relative `Position`). + +- [ ] **Step 4: Run — expect PASS (streaming-center-invariant)** + +- [ ] **Step 5: Full suites green** + +Run all four. Expected: green — the whole physics conformance set passes with `_liveCenter` invisible to physics. + +- [ ] **Step 6: Retire register rows** + +Delete AP-36 (streaming-center physics gate) and the temporary Slice-2 bridge row + the wire-leak row in `docs/architecture/retail-divergence-register.md`. Update the physics digest banner (the #145 entry → RESOLVED/cell-relative shipped). + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.Core/Physics/PhysicsEngine.cs src/AcDream.Core/Physics/PhysicsBody.cs src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md +git commit -m "refactor(physics #145): Slice 6 — _liveCenter/WorldOffset render-only; physics frame-free; retire bridge + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Teleport velocity-idle (separable — the last user-visible bit of #145) + +**Goal:** On teleport arrival, idle the motion state so the player doesn't resume the pre-teleport run vector. Not a cascade fix (the cascade is gone after Slice 3) — just correctness. + +**Files:** +- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (after `PlaceTeleportArrival`'s `SetPosition`, around `:803`; the motion-idle call exists at `:718` `DoMotion(MotionCommand.Ready,1.0f)` and `MotionInterpreter.StopCompletely():510-535`) +- Test: `tests/AcDream.App.Tests/...` motion-state test + +**Read first:** `PlayerMovementController.cs:794-808` (`SetPosition`), `:840-854` (portal-space guard), `:943-977` (velocity re-apply from `get_state_velocity`); `MotionInterpreter.cs:510-535` (`StopCompletely`), `:587-636` (`get_state_velocity`). + +- [ ] **Step 1: Write the failing test** + +Simulate: player running (ForwardCommand=RunForward) → teleport arrival → first `Update()`. Assert resulting velocity is ~zero (motion state idled), not the stale run vector. + +- [ ] **Step 2: Run — expect FAIL (stale run re-applied)** + +- [ ] **Step 3: Idle the motion state on arrival** + +After `SetPosition` in the arrival path, call the motion-idle (`StopCompletely()` / `DoMotion(MotionCommand.Ready, 1.0f)`) so `InterpretedState` resets before the next `Update()` reads `get_state_velocity()`. Faithful to the existing `:718` pattern. + +- [ ] **Step 4: Run — expect PASS** + +- [ ] **Step 5: Suites green; close #145** + +Run all four. Move #145 to **Recently closed** in `docs/ISSUES.md` with the slice SHAs. Update the physics digest + milestones doc (M1.5 far-town round-trip unblocked). Note the live ACE far-town portal round-trip remains the user visual gate. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Input/PlayerMovementController.cs tests/ docs/ISSUES.md +git commit -m "fix(physics #145): Slice 7 — idle motion state on teleport arrival; close #145 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-review (run before execution) + +**Spec coverage:** §2 retail port table → Task 1 (`GetBlockOffset`) + Tasks 3–4 (`adjust_to_outside`, `validate_transition`, `cache_global_curr_center`, contact-plane translate). §3 types → Task 1. §3.2 seam → Tasks 5–6. §3.3 working frame → Task 4. §4 divergence-site table → mapped row-by-row across Tasks 2–7. §5 conformance/transform-on-read → Tasks 2–4 fixture handling + Task 3 `TeleportFarTownRunawayTests`. §6 slices → Tasks 1–7 (1:1). §7 verification items → Task 4 "Read first" + the orchestrator notes. §8 register → per-task register steps. ✅ no gaps. + +**Placeholders:** Task 1 is fully literal. Tasks 2–7 are decomp-port tasks: they name the exact files, the decomp addresses, the test to write, the integration points, and acceptance — the "code" for a faithful port is derived from the cited decomp + existing source by design (the methodology note governs this), reviewed by the orchestrator between slices. No "TBD/handle edge cases" hand-waves. + +**Type consistency:** `Position`/`Frame` (Task 1) ↔ `CellPosition`/`SetCellPosition` (Task 2) ↔ `ResolvedPosition` (Task 2) ↔ membership/wire consumers (Tasks 3,5) ↔ contact-plane `…CellId` translate (Task 4) — names consistent throughout. `GetBlockOffset(source, dest)` signature stable across Tasks 1/4/6. + +## Execution handoff + +Subagent-driven (user-directed): fresh subagent per task, orchestrator reviews each diff against the existing code BEFORE accepting (the CLAUDE.md "subagent needs full context" mitigation), holds the green bar per slice. Slices 1→7 are strictly ordered (each builds on the prior). Live ACE far-town round-trip is the user visual gate after Slice 7.