feat(physics #145): Slice 2a — PhysicsBody carries CellPosition; setter delta-syncs into the cell frame

Add CellPosition (retail Position type) alongside the world Vector3 Position.
The Position setter mirrors each world delta into the cell-local origin and
calls AdjustToOutside only when the local coord crosses a landblock boundary
([0,192) on X or Y), so the within-block cell id is preserved from the wire
seed. SnapToCell seeds both positions from the wire's (cell, local) pair
verbatim — no streaming center involved. Unseeded bodies (ObjCellId==0) and
indoor cells are no-ops in the delta path. UpdatePhysicsInternal's existing
`Position +=` desugars through the new setter automatically; no call sites
changed. 4 new unit tests; full Core suite 1526 passed / 0 failed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-21 10:49:39 +02:00
parent c980763322
commit afe495b9e6
2 changed files with 118 additions and 2 deletions

View file

@ -91,8 +91,73 @@ public sealed class PhysicsBody
// ── struct fields ────────────────────────────────────────────────────── // ── struct fields ──────────────────────────────────────────────────────
// Offsets from acclient_function_map.md §PhysicsObj Struct Layout. // Offsets from acclient_function_map.md §PhysicsObj Struct Layout.
/// <summary>World-space position (no offset in struct — frame origin).</summary> private Vector3 _position;
public Vector3 Position { get; set; }
/// <summary>World-space position (frame origin). #145: the setter mirrors its
/// delta into <see cref="CellPosition"/> so the cell-relative frame stays exact
/// through integration + the resolve-apply (both write here). Placement uses
/// <see cref="SnapToCell"/> instead (no delta).</summary>
public Vector3 Position
{
get => _position;
set
{
Vector3 delta = value - _position;
_position = value;
SyncCellPositionDelta(delta);
}
}
/// <summary>
/// Cell-relative position (retail Position): the (cell, local∈[0,192)) pair.
/// #145 Slice 2 — rides alongside <see cref="Position"/>, becomes authoritative
/// in later slices. Default (ObjCellId==0) until <see cref="SnapToCell"/> seeds it.
/// </summary>
public Position CellPosition { get; private set; }
/// <summary>
/// Placement: set the world position AND seed <see cref="CellPosition"/> 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.
/// </summary>
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));
}
}
/// <summary>Orientation quaternion (struct offsets 0x600x80 column matrix).</summary> /// <summary>Orientation quaternion (struct offsets 0x600x80 column matrix).</summary>
public Quaternion Orientation { get; set; } = Quaternion.Identity; public Quaternion Orientation { get; set; } = Quaternion.Identity;

View file

@ -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);
}
}