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>
336 lines
15 KiB
C#
336 lines
15 KiB
C#
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// timestamp compare (ACE <c>PhysicsObj.is_newer</c> confirms the
|
|
/// semantics; the BN pseudo-C return values are setcc-garbled).</item>
|
|
/// <item>The 0xF74C dispatch INSTANCE_TS gate
|
|
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c>,
|
|
/// acclient_2013_pseudo_c.txt:357214-357239): stale incarnation → drop
|
|
/// BEFORE any movement stamp is touched.</item>
|
|
/// <item><c>CPhysics::SetObjectMovement</c> (0x00509690,
|
|
/// acclient_2013_pseudo_c.txt:271370): MOVEMENT_TS accepts only strictly
|
|
/// newer sequences and stamps BEFORE the server-control gate;
|
|
/// SERVER_CONTROLLED_MOVE_TS drops when the stored stamp is strictly
|
|
/// newer than the incoming one (equal passes).</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class PhysicsTimestampGateTests
|
|
{
|
|
// CPhysicsObj::is_newer(old, new): abs(new-old) > 0x7fff ? new < old : old < new.
|
|
[Theory]
|
|
[InlineData((ushort)0, (ushort)1, true)] // simple newer
|
|
[InlineData((ushort)1, (ushort)0, false)] // simple older
|
|
[InlineData((ushort)5, (ushort)5, false)] // equal is not newer
|
|
[InlineData((ushort)0, (ushort)0x7FFF, true)] // abs diff exactly 0x7fff → normal compare
|
|
[InlineData((ushort)0, (ushort)0x8000, false)] // abs diff 0x8000 → wraparound branch
|
|
[InlineData((ushort)0xFFFF, (ushort)0, true)] // 0 is newer than 0xFFFF (wrap)
|
|
[InlineData((ushort)0xFFFE, (ushort)2, true)] // newer across the seam
|
|
[InlineData((ushort)2, (ushort)0xFFFE, false)] // older across the seam
|
|
public void IsNewer_MatchesRetailWraparoundCompare(ushort oldStamp, ushort newStamp, bool expected)
|
|
{
|
|
Assert.Equal(expected, PhysicsTimestampGate.IsNewer(oldStamp, newStamp));
|
|
}
|
|
|
|
[Fact]
|
|
public void EventBeforeCreateObjectSeed_IsRejected()
|
|
{
|
|
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]
|
|
public void DuplicateMovementSequence_IsDropped()
|
|
{
|
|
// Gate 1 accepts strictly-newer only — an identical movementSeq is a
|
|
// duplicate delivery, not a new command.
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleMovementSequence_IsDropped_ThenNewerAccepted()
|
|
{
|
|
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));
|
|
}
|
|
|
|
[Fact]
|
|
public void MovementSequence_WrapsAroundTheU16Seam()
|
|
{
|
|
// 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 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
|
|
}
|
|
|
|
[Fact]
|
|
public void SeededGate_AcceptsNextSequence_RejectsThePast()
|
|
{
|
|
// The exact case zero-init would break: spawning near a long-lived
|
|
// entity whose movement sequence is already past 0x8000. Retail
|
|
// seeds update_times from CreateObject's PhysicsDesc timestamps
|
|
// (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 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 SameGenerationCreateObject_DoesNotConsumeIndividualChannels()
|
|
{
|
|
// 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));
|
|
|
|
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));
|
|
|
|
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()
|
|
{
|
|
// Retail stamps MOVEMENT_TS BEFORE evaluating the server-control gate
|
|
// (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 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
|
|
Assert.True(gate.TryAcceptMovementEvent(0, 3, 5)); // next movementSeq proceeds
|
|
}
|
|
|
|
[Fact]
|
|
public void EqualServerControl_Passes()
|
|
{
|
|
// 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 PhysicsTimestampGate();
|
|
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
|
|
Assert.True(gate.TryAcceptMovementEvent(0, 1, 7));
|
|
Assert.True(gate.TryAcceptMovementEvent(0, 2, 7));
|
|
}
|
|
|
|
[Fact]
|
|
public void StaleInstance_Drops_WithoutConsumingMovementSequence()
|
|
{
|
|
// The INSTANCE_TS gate runs at dispatch level, BEFORE
|
|
// CPhysics::SetObjectMovement — a stale-incarnation event must not
|
|
// touch the movement stamps.
|
|
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_IsDroppedUntilCreateObjectStartsGeneration()
|
|
{
|
|
// 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.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 PhysicsTimestampGate();
|
|
SeedMovement(gate, instance: 0xFFFE, movement: 0, serverControl: 0);
|
|
Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0));
|
|
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);
|
|
}
|