acdream/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md
Erik ed32db70d7 docs(#145): implementation plan — cell-relative physics frame (7 slices)
Bite-sized, TDD-structured 7-slice plan for subagent-driven execution:
(1) Position/Frame types + GetBlockOffset, (2) PhysicsBody carries
Position, (3) membership via AdjustToOutside — closes the cascade,
(4) inter-tick collision state cell-relative + validate_transition
lockstep, (5) wire off Position, (6) _liveCenter render-only, (7)
teleport velocity-idle. Conformance via transform-on-read.

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

558 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | 24 |
---
## 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;
/// <summary>
/// Retail <c>Frame</c> (acclient.h:30647). <see cref="Origin"/> is LOCAL to the
/// owning cell: outdoor → X/Y ∈ [0,192) within the landblock, Z = height;
/// indoor → the EnvCell-relative placement. <see cref="Orientation"/> replaces
/// retail's <c>m_fl2gv</c> 3×3 local→global matrix (equivalent rotation).
/// </summary>
public readonly record struct Frame(Vector3 Origin, Quaternion Orientation);
/// <summary>
/// Retail <c>Position</c> (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 <see cref="Frame"/>. Neither
/// half is ever reconstructed from a streaming center — see #145 design spec.
/// </summary>
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
/// <summary>
/// <c>LandDefs::get_block_offset</c> (pc:69189, @0x0043e630): world-meter offset
/// from <paramref name="source"/>'s landblock origin to <paramref name="dest"/>'s.
/// ZeroVector when both ids share a landblock (high words equal). The decomp's
/// <c>block_byte&lt;&lt;3</c> converts to lcoord (cell) units and the literal
/// <c>·24 m/cell</c> then nets to <c>(Δlandblock)·192 m</c> per axis. The
/// degenerate <c>arg==0</c> 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.
/// </summary>
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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>Cell-relative source of truth (retail Position). #145 Slice 2.</summary>
public Position CellPosition { get; private set; }
/// <summary>
/// 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.
/// </summary>
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 36.
> **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
/// <summary>#145: the canonicalized cell-relative result. Source of truth from
/// Slice 3 onward; Position/CellId remain as the world-frame bridge until Slice 6.</summary>
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) <noreply@anthropic.com>"
```
---
## 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 57 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) <noreply@anthropic.com>"
```
---
## 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 <SeamTestName>`
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## Self-review (run before execution)
**Spec coverage:** §2 retail port table → Task 1 (`GetBlockOffset`) + Tasks 34 (`adjust_to_outside`, `validate_transition`, `cache_global_curr_center`, contact-plane translate). §3 types → Task 1. §3.2 seam → Tasks 56. §3.3 working frame → Task 4. §4 divergence-site table → mapped row-by-row across Tasks 27. §5 conformance/transform-on-read → Tasks 24 fixture handling + Task 3 `TeleportFarTownRunawayTests`. §6 slices → Tasks 17 (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 27 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.