diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 67882f0b..62fbc547 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -91,8 +91,73 @@ public sealed class PhysicsBody
// ── struct fields ──────────────────────────────────────────────────────
// Offsets from acclient_function_map.md §PhysicsObj Struct Layout.
- /// World-space position (no offset in struct — frame origin).
- public Vector3 Position { get; set; }
+ private Vector3 _position;
+
+ /// World-space position (frame origin). #145: the setter mirrors its
+ /// delta into so the cell-relative frame stays exact
+ /// through integration + the resolve-apply (both write here). Placement uses
+ /// instead (no delta).
+ public Vector3 Position
+ {
+ get => _position;
+ set
+ {
+ Vector3 delta = value - _position;
+ _position = value;
+ SyncCellPositionDelta(delta);
+ }
+ }
+
+ ///
+ /// Cell-relative position (retail Position): the (cell, local∈[0,192)) pair.
+ /// #145 Slice 2 — rides alongside , becomes authoritative
+ /// in later slices. Default (ObjCellId==0) until seeds it.
+ ///
+ public Position CellPosition { get; private set; }
+
+ ///
+ /// Placement: set the world position AND seed from the
+ /// wire's (cell, local) directly — NO streaming center, NO delta. Stores the wire
+ /// values verbatim; the caller has already decoded the wire into a consistent
+ /// (cellId, cellLocal) pair. Caller (Slice 2b) supplies the wire (cell, localX/Y/Z)
+ /// from the inbound position update / teleport arrival.
+ ///
+ public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal)
+ {
+ _position = worldPos;
+ CellPosition = new Position(cellId, new CellFrame(cellLocal, Orientation));
+ }
+
+ // Mirror a world-position translation into the cell-relative frame. Velocity is
+ // frame-invariant under translation, so the same delta applies to the local origin;
+ // AdjustToOutside re-wraps into [0,192) and bumps the cell on a landblock crossing.
+ // Only for a SEEDED OUTDOOR cell (player). Unseeded bodies (ObjCellId==0, e.g. remote
+ // entities) and indoor cells are left untouched here.
+ // We only invoke AdjustToOutside when the local coordinate leaves [0,192) — i.e. on
+ // an actual landblock-boundary crossing. Within the same landblock the cell id is left
+ // unchanged (the wire seed is authoritative for which cell index we occupy).
+ private void SyncCellPositionDelta(Vector3 delta)
+ {
+ uint cell = CellPosition.ObjCellId;
+ if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u))
+ return;
+ Vector3 local = CellPosition.Frame.Origin + delta;
+ // Only call AdjustToOutside on a landblock-boundary crossing (local X or Y
+ // leaves [0, BlockLength)). Within the block the cell index is kept as-is.
+ if (local.X < 0f || local.X >= LandDefs.BlockLength ||
+ local.Y < 0f || local.Y >= LandDefs.BlockLength)
+ {
+ uint adjusted = cell;
+ if (LandDefs.AdjustToOutside(ref adjusted, ref local))
+ CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation));
+ // else: map-edge (AdjustToOutside failed) — leave CellPosition unchanged.
+ // Slice 3 owns proper map-edge membership; this slice is behaviour-neutral.
+ }
+ else
+ {
+ CellPosition = new Position(cell, new CellFrame(local, CellPosition.Frame.Orientation));
+ }
+ }
/// Orientation quaternion (struct offsets 0x60–0x80 column matrix).
public Quaternion Orientation { get; set; } = Quaternion.Identity;
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs
new file mode 100644
index 00000000..bf06afc4
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs
@@ -0,0 +1,51 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class PhysicsBodyCellSyncTests
+{
+ [Fact]
+ public void SnapToCell_SeedsCellPosition_AndWorldPosition()
+ {
+ var body = new PhysicsBody();
+ body.SnapToCell(0xC95B0001u, worldPos: new Vector3(1000f, 2000f, 12f), cellLocal: new Vector3(14.8f, 0.3f, 12f));
+ Assert.Equal(0xC95B0001u, body.CellPosition.ObjCellId);
+ Assert.Equal(new Vector3(14.8f, 0.3f, 12f), body.CellPosition.Frame.Origin);
+ Assert.Equal(new Vector3(1000f, 2000f, 12f), body.Position);
+ }
+
+ [Fact]
+ public void PositionDelta_MirrorsIntoCellPosition_WithinLandblock()
+ {
+ var body = new PhysicsBody();
+ body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 50f, 12f));
+ body.Position += new Vector3(5f, 3f, 0f); // move within the landblock
+ Assert.Equal(new Vector3(105f, 53f, 12f), body.CellPosition.Frame.Origin);
+ Assert.Equal(0xC95B0001u, body.CellPosition.ObjCellId); // no [0,192) crossing → same cell
+ Assert.Equal(new Vector3(1005f, 2003f, 12f), body.Position); // world still authoritative + moved
+ }
+
+ [Fact]
+ public void PositionDelta_AcrossSouthLandblockEdge_BumpsCellAndRewraps()
+ {
+ var body = new PhysicsBody();
+ // local Y just above 0 in 0xC95B (lbY=0x5B); move south past 0 → south neighbour 0xC95A, local Y near 192.
+ body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 0.3f, 12f));
+ body.Position += new Vector3(0f, -1f, 0f);
+ int lbY = (int)((body.CellPosition.ObjCellId >> 16) & 0xFFu);
+ Assert.Equal(0x5A, lbY); // bumped to south neighbour
+ Assert.InRange(body.CellPosition.Frame.Origin.Y, 190f, 192f); // re-wrapped near top of southern block
+ }
+
+ [Fact]
+ public void PositionDelta_WithoutSeed_LeavesCellPositionDefault()
+ {
+ // A body that was never SnapToCell'd (e.g. a remote entity) must NOT get
+ // a synthesized cell — CellPosition stays default, no AdjustToOutside.
+ var body = new PhysicsBody();
+ body.Position += new Vector3(10f, 10f, 0f);
+ Assert.Equal(0u, body.CellPosition.ObjCellId);
+ }
+}