feat(net): port retail physics spawn and event timestamps

Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 00:22:17 +02:00
parent d53fe30ffe
commit 8a5d77f7f4
50 changed files with 3809 additions and 649 deletions

View file

@ -4,8 +4,8 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Covers <see cref="MotionSequenceGate"/> — the inbound movement-event
/// staleness gate ported from retail (L.2g S1, deviation DEV-6):
/// Covers <see cref="PhysicsTimestampGate"/> — the inbound physics-event
/// staleness gate ported from retail:
///
/// <list type="bullet">
/// <item><c>CPhysicsObj::is_newer</c> (0x00451ad0) — the wraparound u16
@ -22,7 +22,7 @@ namespace AcDream.Core.Tests.Physics;
/// newer than the incoming one (equal passes).</item>
/// </list>
/// </summary>
public class MotionSequenceGateTests
public class PhysicsTimestampGateTests
{
// CPhysicsObj::is_newer(old, new): abs(new-old) > 0x7fff ? new < old : old < new.
[Theory]
@ -36,14 +36,17 @@ public class MotionSequenceGateTests
[InlineData((ushort)2, (ushort)0xFFFE, false)] // older across the seam
public void IsNewer_MatchesRetailWraparoundCompare(ushort oldStamp, ushort newStamp, bool expected)
{
Assert.Equal(expected, MotionSequenceGate.IsNewer(oldStamp, newStamp));
Assert.Equal(expected, PhysicsTimestampGate.IsNewer(oldStamp, newStamp));
}
[Fact]
public void FirstEvent_WithFreshMovementSequence_IsAccepted()
public void EventBeforeCreateObjectSeed_IsRejected()
{
var gate = new MotionSequenceGate();
Assert.True(gate.TryAcceptMovementEvent(instanceSeq: 0, movementSeq: 1, serverControlSeq: 0));
var gate = new PhysicsTimestampGate();
Assert.False(gate.TryAcceptMovementEvent(instance: 0, movement: 1, serverControlledMove: 0));
Assert.False(gate.TryAcceptStateEvent(instance: 0, state: 1));
Assert.False(gate.TryAcceptVectorEvent(instance: 0, vector: 1));
Assert.False(gate.TryAcceptObjDescEvent(instance: 0, objDesc: 1));
}
[Fact]
@ -51,7 +54,8 @@ public class MotionSequenceGateTests
{
// Gate 1 accepts strictly-newer only — an identical movementSeq is a
// duplicate delivery, not a new command.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
Assert.False(gate.TryAcceptMovementEvent(0, 5, 0));
}
@ -59,7 +63,8 @@ public class MotionSequenceGateTests
[Fact]
public void StaleMovementSequence_IsDropped_ThenNewerAccepted()
{
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
Assert.False(gate.TryAcceptMovementEvent(0, 4, 0)); // reordered straggler
Assert.True(gate.TryAcceptMovementEvent(0, 6, 0));
@ -71,8 +76,8 @@ public class MotionSequenceGateTests
// Stamps live in u16 space and wrap; retail seeds them from the
// CreateObject PhysicsDesc timestamp block, so a gate near the seam
// must accept the post-wrap values as newer.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 0, movementSeq: 0xFFFD, serverControlSeq: 0);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0xFFFD, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 0xFFFE, 0));
Assert.True(gate.TryAcceptMovementEvent(0, 2, 0)); // 2 is newer than 0xFFFE by wrap rule
Assert.False(gate.TryAcceptMovementEvent(0, 0xFFFE, 0)); // and the old stamp is now stale
@ -87,34 +92,48 @@ public class MotionSequenceGateTests
// (ACE WorldObject_Networking.cs:411-420 writes all 9 in enum
// order), so the first UM after spawn is judged against the
// seeded stamp, not zero.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
Assert.False(gate.TryAcceptMovementEvent(3, 0x8FFF, 2)); // pre-spawn straggler
}
[Fact]
public void Reseed_WithOlderStamps_DoesNotRegress()
public void SameGenerationCreateObject_DoesNotConsumeIndividualChannels()
{
// The #138 rehydrate path replays RETAINED CreateObject spawns
// through the normal spawn handler (GameWindow.RehydrateServerEntities
// ForLandblock) — re-seeding from that cached spawn must not roll the
// live stamps backward, or a post-rehydrate straggler UM would be
// accepted as fresh. First Seed adopts wholesale (fresh object);
// later Seeds only advance.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
// Retail HandleCreateObject keeps an equal-instance object and routes
// each field through its ordinary channel handler. Classifying the
// CreateObject itself must therefore consume no channel timestamp.
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
gate.Seed(instanceSeq: 3, movementSeq: 0x8000, serverControlSeq: 2); // stale replay
Assert.False(gate.TryAcceptMovementEvent(3, 0x9001, 2)); // still a duplicate
var disposition = gate.SeedForCreateObject(
0, 0x9002, 0, 0, 0, 2, 0, 0, 3);
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, disposition);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9002, 2));
gate.Seed(instanceSeq: 4, movementSeq: 0x9010, serverControlSeq: 2); // genuine re-create
SeedMovement(gate, instance: 4, movement: 0x9010, serverControl: 2); // genuine re-create
Assert.False(gate.TryAcceptMovementEvent(3, 0x9011, 2)); // old incarnation stale
Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2));
}
[Fact]
public void SameGenerationCreateObject_MixedChannelsAreAcceptedIndependently()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 10, 10, 10, 20, 5, 0, 10, 3);
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration,
gate.SeedForCreateObject(9, 11, 11, 9, 20, 5, 0, 11, 3));
Assert.False(gate.TryAcceptPositionChannelEvent(3, 9));
Assert.True(gate.TryAcceptMovementEvent(3, 11, 5));
Assert.True(gate.TryAcceptStateEvent(3, 11));
Assert.False(gate.TryAcceptVectorEvent(3, 9));
Assert.True(gate.TryAcceptObjDescEvent(3, 11));
}
[Fact]
public void StaleServerControl_Drops_ButMovementStampStillAdvances()
{
@ -122,7 +141,8 @@ public class MotionSequenceGateTests
// (0x00509690: update_times[1] written at 0x005096dd, the SC compare
// runs after) — a movement event dropped for stale server-control
// still consumes its movement sequence.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 1, 5));
Assert.False(gate.TryAcceptMovementEvent(0, 2, 4)); // sc=4 older than stored 5 → drop
Assert.False(gate.TryAcceptMovementEvent(0, 2, 5)); // movementSeq 2 was consumed by the drop
@ -134,7 +154,8 @@ public class MotionSequenceGateTests
{
// The SC gate drops only when the STORED stamp is strictly newer than
// the incoming one; equal means "same server-control era" and applies.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 1, 7));
Assert.True(gate.TryAcceptMovementEvent(0, 2, 7));
}
@ -145,31 +166,171 @@ public class MotionSequenceGateTests
// The INSTANCE_TS gate runs at dispatch level, BEFORE
// CPhysics::SetObjectMovement — a stale-incarnation event must not
// touch the movement stamps.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 2, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(2, 1, 0));
Assert.False(gate.TryAcceptMovementEvent(1, 2, 0)); // stale incarnation
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // seq 2 was NOT consumed by the instance drop
}
[Fact]
public void NewerInstance_IsAdoptedAndApplied()
public void NewerInstance_IsDroppedUntilCreateObjectStartsGeneration()
{
// DIVERGENCE (register row added with this port): retail queues the
// blob until the newer incarnation exists (SmartBox::QueueBlobForObject,
// dispatch return 4); acdream adopts the newer instance stamp and
// applies immediately.
var gate = new MotionSequenceGate();
// Retail queues this blob; AD-32 records that acdream drops it until
// LiveEntityRuntime owns that queue. It must never touch the old body.
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 1, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(1, 1, 0));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // newer incarnation adopted
Assert.False(gate.TryAcceptMovementEvent(1, 3, 0)); // old incarnation now stale
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0));
Assert.True(gate.TryAcceptMovementEvent(1, 2, 0)); // future event consumed nothing
}
[Fact]
public void InstanceCompare_WrapsAroundTheU16Seam()
{
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 0xFFFE, movementSeq: 0, serverControlSeq: 0);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0xFFFE, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // instance 2 newer than 0xFFFE by wrap
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0)); // future incarnation queues in retail
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration,
gate.SeedForCreateObject(0, 1, 0, 0, 0, 0, 0, 0, 2));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0));
}
[Fact]
public void StateAndVectorChannelsAdvanceIndependently()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 20, 30, 40, 50, 60, 70, 80, 90);
Assert.True(gate.TryAcceptStateEvent(90, 31));
Assert.False(gate.TryAcceptStateEvent(90, 31));
Assert.True(gate.TryAcceptVectorEvent(90, 41));
Assert.False(gate.TryAcceptVectorEvent(90, 40));
}
[Fact]
public void PositionRollsBackWhenNewerTeleportAlreadyExists()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 19, 0, isLocalPlayer: false));
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
}
[Fact]
public void FreshTeleportAndForcePositionAdvanceTheirOwnChannels()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.ForcePosition,
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
Assert.Equal((ushort)9, gate.PositionTimestamp);
Assert.Equal((ushort)20, gate.TeleportTimestamp);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
}
[Fact]
public void FreshForceWithNewerTeleport_UsesNormalPositionPath()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(1, 11, 21, 31, isLocalPlayer: true));
Assert.Equal((ushort)11, gate.PositionTimestamp);
Assert.Equal((ushort)21, gate.TeleportTimestamp);
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
}
[Fact]
public void FreshForceIsConsumedWhenOlderTeleportRejectsPose()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 19, 31, isLocalPlayer: true));
Assert.Equal((ushort)10, gate.PositionTimestamp);
Assert.Equal((ushort)20, gate.TeleportTimestamp);
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
}
[Fact]
public void ParentPickupAndPositionShareOnePositionChannel()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
Assert.True(gate.TryAcceptPositionChannelEvent(1, 11));
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
Assert.False(gate.TryAcceptPositionChannelEvent(1, 10));
Assert.True(gate.TryAcceptPositionChannelEvent(1, 12));
}
[Fact]
public void NewCreateObjectGenerationReplacesLowerChannelStampsWholesale()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1);
var disposition = gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, disposition);
Assert.True(gate.TryAcceptStateEvent(2, 2));
Assert.True(gate.TryAcceptVectorEvent(2, 2));
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(2, 2, 1, 1, isLocalPlayer: false));
}
[Fact]
public void StaleCreateObjectAndDeleteCannotAffectCurrentGeneration()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration,
gate.SeedForCreateObject(500, 500, 500, 500, 500, 500, 500, 500, 1));
Assert.False(gate.TryAcceptDeleteEvent(1));
Assert.True(gate.TryAcceptDeleteEvent(2));
Assert.False(gate.TryAcceptDeleteEvent(2, isLocalPlayer: true));
}
[Fact]
public void ObjDescRequiresCurrentInstanceAndStrictlyNewerSequence()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(0, 0, 0, 0, 0, 0, 0, 0xFFFE, 7);
Assert.False(gate.TryAcceptObjDescEvent(6, 0xFFFF));
Assert.True(gate.TryAcceptObjDescEvent(7, 0xFFFF));
Assert.True(gate.TryAcceptObjDescEvent(7, 0));
Assert.False(gate.TryAcceptObjDescEvent(7, 0));
}
[Fact]
public void PlayerTeleportStartChecksButDoesNotAdvanceTeleportChannel()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(0, 0, 0, 0, 10, 0, 0, 0, 1);
Assert.True(gate.IsFreshTeleportStart(10));
Assert.False(gate.IsFreshTeleportStart(9));
Assert.True(gate.IsFreshTeleportStart(11));
Assert.True(gate.IsFreshTeleportStart(11));
Assert.Equal((ushort)10, gate.TeleportTimestamp);
}
private static void SeedMovement(
PhysicsTimestampGate gate,
ushort instance,
ushort movement,
ushort serverControl) =>
gate.SeedForCreateObject(0, movement, 0, 0, 0, serverControl, 0, 0, instance);
}