acdream/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md
2026-06-21 11:08:13 +02:00

41 KiB
Raw Blame History

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) CellFrame + Position value types (CellFrame = retail Frame, renamed to dodge DatReaderWriter.Types.Frame) 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 (the value type is named CellFrame, NOT FrameFrame collides with DatReaderWriter.Types.Frame used in physics-adjacent code; the Position.Frame member keeps retail's name):

using System.Numerics;

namespace AcDream.Core.Physics;

/// <summary>
/// Retail <c>Frame</c> (acclient.h:30647) — named <c>CellFrame</c> here to avoid
/// colliding with <c>DatReaderWriter.Types.Frame</c>. <see cref="Origin"/> is LOCAL
/// to the owning cell: outdoor → X/Y ∈ [0,192), Z = height; indoor → EnvCell-relative.
/// <see cref="Orientation"/> replaces retail's <c>m_fl2gv</c> 3×3 matrix.
/// </summary>
public readonly record struct CellFrame(Vector3 Origin, Quaternion Orientation);

/// <summary>
/// Retail <c>Position</c> (acclient.h:30658): the (which-cell, where-inside) pair.
/// Full 32-bit cell id + the cell-local <see cref="CellFrame"/>. Never reconstructed
/// from a streaming center — see #145 design spec. The <c>Frame</c> member keeps
/// retail's name.
/// </summary>
public readonly record struct Position(uint ObjCellId, CellFrame Frame)
{
    public Position(uint objCellId, Vector3 origin, Quaternion orientation)
        : this(objCellId, new CellFrame(origin, orientation)) { }
}
  • Step 2: Write the failing GetBlockOffset test

tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs:

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):

    /// <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
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>"

Execution note (2026-06-21): Slice 2 split into 2a (Core: PhysicsBody.CellPosition + SnapToCell + setter delta-sync, SHIPPED afe495b+7928b44) and 2b (App: seed SnapToCell at the placement sites). 2b folds into Task 3 — the seed is only verifiable once membership consumes it, so they ship together under the runaway test. The confirmed mechanism is wire-seed + mirrored deltas (no _liveCenter in Core); the WorldPosition/IWorldFrame bridge described below was superseded by the setter-delta-sync design (no separate bridge type needed).

Task 2: PhysicsBody carries Position; 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:

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:

    /// <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):

    /// <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
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.

THE MECHANISM (designed + verified 2026-06-21 — the carried-anchor): the landblock world origin acdream needs is exactly blockOrigin = body.Position body.CellPosition.Frame.Origin (world pos minus the carried cell-local). This is the true landblock origin derived from the carried frame — correct even for an UNSTREAMED neighbour, where TryGetTerrainOrigin returns (0,0). Verified against the runaway: at the first far-town tick body.Position≈(14.8,0.088,12) (streaming-rel) and CellPosition.Frame.Origin≈(14.8,191.9,12) (local in 0xC95A), so blockOrigin ≈ (0,192) — exactly the value the handoff confirmed correct for 0xC95A. Feeding this blockOrigin into the EXISTING pick (pickPos = worldSphereCenter blockOrigin at CellTransit.cs:845) and AddAllOutsideCells makes the whole downstream membership cascade-proof with no rewrite of the pick logic.

Implementation steps:

  1. Seed _body.SnapToCell(cellId, worldPos, wireLocal) at the 3 player-placement sites — login spawn (GameWindow.cs:~2930, wire local (p.PositionX,Y,Z), cell p.LandblockId), teleport arrival (~:5474/:5537 — seed the RESOLVED cell + its local), inbound player update. Without a seed, CellPosition.ObjCellId==0 and the fix no-ops (legacy fallback). Behavior-neutral.
  2. Thread Vector3? carriedBlockOrigin into CellTransit.FindCellSet/FindCellList/BuildCellSetAndPickContaining (default null → legacy TryGetTerrainOrigin). When supplied, it REPLACES the :736 derivation. Compute it in PhysicsEngine.ResolveWithTransition from the passed body (body.CellPosition.ObjCellId outdoor & seeded → body.Position body.CellPosition.Frame.Origin, else null), thread through the sweep (RunCheckOtherCellsAndAdvanceFindCellSet) in TransitionTypes.cs. ⚠ The sweep threading is the delicate part — RunCheckOtherCellsAndAdvance is in the ~2,300-line TransitionTypes; the resolve body must reach the FindCellSet call (add a field on the transition/sphere-path state set at resolve entry). Read that path fully before editing.
  3. Make membership authoritative: the controller commits _body.CellPosition.ObjCellId (or the now-correct resolveResult.CellId) — they agree once blockOrigin is the carried anchor. Delete the legacy-anchor comment at :732-735.
  4. Test TeleportFarTownRunawayTests from desync-capture.jsonl (south + east edge, unregistered neighbour, assert no march). Hold every existing membership/replay test green.

Note: the OTHER TryGetTerrainOrigin site (CellTransit.cs:484, BuildShadowCellSet) is registration-side collision (Slice 4 concern), NOT the player pick — leave it for Slice 4.

⚠ Edge cases surfaced during design (2026-06-21) — handle these or regress #98#112:

  • cellId ↔ anchor must share a landblock. The carried anchor body.Position CellPosition.Frame.Origin is the origin of CellPosition's landblock; the pick's AdjustToOutside starts from the passed cellId. If cellId is the lagging/marched controller CellId while CellPosition is correct, the pick mixes a marched landblock with a correct local → garbage. Fix: the controller passes _body.CellPosition.ObjCellId as the resolve cell for the OUTDOOR case (so cellId == CellPosition.ObjCellId, consistent with the anchor); indoor keeps the BSP-tracked CellId. Selection: (_body.CellPosition.ObjCellId outdoor & seeded && current is outdoor) ? CellPosition.ObjCellId : CellId.
  • CellPosition goes stale indoors. The Slice-2a setter only maintains CellPosition for outdoor cells (low word 1..0x40); while the player is inside a building (CellId indoor) it is NOT updated. On building EXIT (indoor→outdoor) the carried anchor would be stale. Fix: re-seed SnapToCell from the resolve's outdoor result when membership transitions indoor→outdoor (the resolve already returns the correct outdoor cell + the world position gives the local via the just-computed anchor). Verify with an indoor↔outdoor round-trip replay (CellarUp / cottage harness) in addition to the far-town test.
  • Sweep threading vehicle: add Vector3? CarriedBlockOrigin to SpherePath (alongside CheckCellId, ~:336); set it in PhysicsEngine.ResolveWithTransition right after InitPath (:975) from body; read it at the FindCellSet call (TransitionTypes.cs:2291) and pass through FindCellSetFindCellListBuildCellSetAndPickContaining (default null = legacy TryGetTerrainOrigin, so every existing caller/test is unaffected).

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:

[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
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
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
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
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
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.