365 lines
16 KiB
C#
365 lines
16 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 PreviewCreateObject_MatchesSeedDispositionWithoutMutation()
|
|
{
|
|
var gate = new PhysicsTimestampGate();
|
|
|
|
Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration,
|
|
gate.PreviewCreateObject(instance: 7));
|
|
Assert.Equal(CreateObjectTimestampDisposition.InitialGeneration,
|
|
gate.SeedForCreateObject(10, 20, 30, 40, 50, 60, 70, 80, 7));
|
|
|
|
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration,
|
|
gate.PreviewCreateObject(instance: 7));
|
|
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration,
|
|
gate.PreviewCreateObject(instance: 8));
|
|
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration,
|
|
gate.PreviewCreateObject(instance: 6));
|
|
|
|
// Preview is observational: all timestamp channels still compare
|
|
// against the original instance-seven seed afterward.
|
|
Assert.True(gate.TryAcceptMovementEvent(
|
|
instance: 7,
|
|
movement: 21,
|
|
serverControlledMove: 60));
|
|
Assert.False(gate.TryAcceptMovementEvent(
|
|
instance: 8,
|
|
movement: 22,
|
|
serverControlledMove: 60));
|
|
}
|
|
|
|
[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);
|
|
}
|