39 KiB
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 | 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 afterAdjustToOutside, before the privateCellLowInRange) - 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 Frame — Frame
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
GetBlockOffsettest
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
GetBlockOffsetfaithfully
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<<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, SHIPPEDafe495b+7928b44) and 2b (App: seedSnapToCellat 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_liveCenterin Core); theWorldPosition/IWorldFramebridge 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(thePositionfield) - 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/CellPositionmissing)
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter PhysicsBodyPositionFrameTests
Expected: build error.
- Step 3: Add
CellPosition+SetCellPositiontoPhysicsBody
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 3–6.
Orchestrator note: This back-compat
Positionaccessor 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
ResolveResultto carry thePosition
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:
- Seed
_body.SnapToCell(cellId, worldPos, wireLocal)at the 3 player-placement sites — login spawn (GameWindow.cs:~2930, wire local(p.PositionX,Y,Z), cellp.LandblockId), teleport arrival (~:5474/:5537— seed the RESOLVED cell + its local), inbound player update. Without a seed,CellPosition.ObjCellId==0and the fix no-ops (legacy fallback). Behavior-neutral. - Thread
Vector3? carriedBlockOriginintoCellTransit.FindCellSet/FindCellList/BuildCellSetAndPickContaining(defaultnull→ legacyTryGetTerrainOrigin). When supplied, it REPLACES the:736derivation. Compute it inPhysicsEngine.ResolveWithTransitionfrom the passedbody(body.CellPosition.ObjCellIdoutdoor & seeded →body.Position − body.CellPosition.Frame.Origin, elsenull), thread through the sweep (RunCheckOtherCellsAndAdvance→FindCellSet) inTransitionTypes.cs. ⚠ The sweep threading is the delicate part —RunCheckOtherCellsAndAdvanceis in the ~2,300-lineTransitionTypes; the resolvebodymust reach theFindCellSetcall (add a field on the transition/sphere-path state set at resolve entry). Read that path fully before editing. - Make membership authoritative: the controller commits
_body.CellPosition.ObjCellId(or the now-correctresolveResult.CellId) — they agree onceblockOriginis the carried anchor. Delete the legacy-anchor comment at:732-735. - Test
TeleportFarTownRunawayTestsfromdesync-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.
Files:
- Modify:
src/AcDream.Core/Physics/CellTransit.cs:732-736(the discarded-bool fallback),:242(AddAllOutsideCellsblock-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 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
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-151become cell-relative + their…CellId;UpdatePhysicsInternal:381integrate-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/WalkableVerticesin their owning cell's frame; keepContactPlaneCellIdas the anchor. In the sweep, before using the contact plane, translate it intocheck_pos's working frame viaGetBlockOffset(check_pos.ObjCellId, ContactPlaneCellId)iffContactPlaneCellId != 0(faithful to @0x0050A592). -
curr_pos/check_posbecomePosition.SetCheckPosupdatescheck_pos.Frametogether with its cell id (never the id alone). -
validate_transition: adoptcurr_pos.ObjCellId = check_pos.ObjCellIdANDcurr_pos.Frame = check_pos.Frametogether; then rebuild the working center (cache_global_curr_center:Orientation·local + Origin, verified order). -
In
UpdatePhysicsInternal, after thePosition += Velocity*dt …integration, runAdjustToOutsideon 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.cscarefully; 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-levelPosition→wire→Positionround-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, theResolveloop:606-625,:858-861) - Modify:
src/AcDream.Core/Physics/PhysicsBody.cs(remove the deprecatedPositionbridge accessor; physics readsCellPosition) - 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(afterPlaceTeleportArrival'sSetPosition, around:803; the motion-idle call exists at:718DoMotion(MotionCommand.Ready,1.0f)andMotionInterpreter.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 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.