diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 62fbc547..32d4b928 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -117,46 +117,46 @@ public sealed class PhysicsBody /// /// 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. + /// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the + /// seed is canonicalized via (retail + /// SetPositionInternal/adjust_to_outside @0x00504A40): the cell index + /// is re-derived from the landblock-local position (low word = floor(local/24)) and + /// the origin wrapped into [0,192). This is the #107 protection — never trust a + /// server (cell, pos) pair without re-deriving the cell. Indoor EnvCell claims + /// (low word >= 0x100) are validated by the BSP/spawn-gate path — seeded verbatim. + /// Caller (Slice 2b) supplies the wire (cell, localX/Y/Z) from the inbound position + /// update / teleport arrival. stays authoritative for + /// the world frame; canonicalizing the (cell, local) decomposition leaves the world + /// point unchanged for an in-range local. /// public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal) { _position = worldPos; - CellPosition = new Position(cellId, new CellFrame(cellLocal, Orientation)); + uint cell = cellId; + Vector3 local = cellLocal; + if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u) + LandDefs.AdjustToOutside(ref cell, ref local); + CellPosition = new Position(cell, new CellFrame(local, 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). + // frame-invariant under translation, so the same delta applies to the local origin. + // AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m + // cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor + // membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid + + // [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell; + // unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped. 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)); - } + 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. } /// Orientation quaternion (struct offsets 0x60–0x80 column matrix). diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs index bf06afc4..3c1e22ed 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs @@ -4,46 +4,63 @@ using Xunit; namespace AcDream.Core.Tests.Physics; +// #145 Slice 2a — PhysicsBody carries a cell-relative CellPosition that rides alongside +// the world Vector3 Position. The wire's local is LANDBLOCK-relative [0,192); the cell id +// low word (which 24 m cell) = floor(local/24), so a consistent pair has +// cell.lowword == cellIndex(local). Placement canonicalizes the outdoor seed (retail +// SetPositionInternal/adjust_to_outside); motion mirrors the world delta into the local +// and re-derives the cell every tick via AdjustToOutside. public class PhysicsBodyCellSyncTests { [Fact] - public void SnapToCell_SeedsCellPosition_AndWorldPosition() + public void SnapToCell_ConsistentOutdoorPair_SeedsAsGiven() { 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); + // cell 0x23 = (lx=4, ly=2); local (100,50) is in that cell (floor(100/24)=4, floor(50/24)=2). + body.SnapToCell(0xC95B0023u, worldPos: new Vector3(1000f, 2000f, 12f), cellLocal: new Vector3(100f, 50f, 12f)); + Assert.Equal(0xC95B0023u, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(100f, 50f, 12f), body.CellPosition.Frame.Origin); Assert.Equal(new Vector3(1000f, 2000f, 12f), body.Position); } [Fact] - public void PositionDelta_MirrorsIntoCellPosition_WithinLandblock() + public void SnapToCell_InconsistentOutdoorPair_CanonicalizesCellFromLocal() { + // #107 protection: retail re-derives the cell from the position on placement. + // (cell 0x01, local in cell 0x23) is fixed to 0x23. 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 + Assert.Equal(0xC95B0023u, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(100f, 50f, 12f), body.CellPosition.Frame.Origin); + } + + [Fact] + public void PositionDelta_WithinLandblock_UpdatesCellIndex() + { + var body = new PhysicsBody(); + body.SnapToCell(0xC95B0023u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 50f, 12f)); + body.Position += new Vector3(0f, 24f, 0f); // local (100,74) → cell (4,3) + Assert.Equal(new Vector3(100f, 74f, 12f), body.CellPosition.Frame.Origin); + // low word for (lx=4, ly=3): (ly&7) + ((lx&7)<<3) + 1 = 3 + 32 + 1 = 0x24 + Assert.Equal(0xC95B0024u, body.CellPosition.ObjCellId); + Assert.Equal(new Vector3(1000f, 2024f, 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); + body.Position += new Vector3(0f, -1f, 0f); // local Y → -0.7 → south neighbour, ~191.3 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 + Assert.Equal(0x5A, lbY); // bumped to south neighbour 0xC95A + 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. + // A body never SnapToCell'd (e.g. a remote entity) gets no synthesized cell. var body = new PhysicsBody(); body.Position += new Vector3(10f, 10f, 0f); Assert.Equal(0u, body.CellPosition.ObjCellId);