feat(L.2g-S1): retail movement-event staleness gate (DEV-6)
Port retail's three-stamp inbound gate for 0xF74C UpdateMotion: - MotionSequenceGate (Core/Physics): CPhysicsObj::is_newer (0x00451ad0) wraparound u16 compare, verbatim per ACE PhysicsObj.is_newer (the BN pseudo-C setcc returns are garbled; ACE + branch structure are the oracle). Gates: INSTANCE_TS at dispatch (stale incarnation drops before any stamp is touched), MOVEMENT_TS strictly-newer (stamped BEFORE the server-control check, per CPhysics::SetObjectMovement 0x00509690), SERVER_CONTROLLED_MOVE_TS drop-when-stored-newer. - Seed from CreateObject's PhysicsDesc timestamp block (index 1 = ObjectMovement now parsed; ACE WorldObject_Networking.cs:411-420 order) — without seeding, entities whose movement sequence is past 0x8000 at spawn would drop every UM against a zero stamp. Adopt-on-first / advance-only-after, so the #138 rehydrate replay of retained spawns cannot regress live stamps. - UpdateMotion + EntitySpawn now carry instance/movement/serverControl sequences + isAutonomous (was parsed-past; isAutonomous feeds the S2 funnel's last_move_was_autonomous). Gate wired at the top of OnLiveMotionUpdated before any state mutation; [UM_STALE] diag under ACDREAM_DUMP_MOTION / ACDREAM_REMOTE_VEL_DIAG; gate dropped with the entity on DeleteObject. Register: AD-32 added (adopt-newer-incarnation instead of retail's QueueBlobForObject); TS-26 updated (UM side closed, UP side open). Deviation map: docs/research/2026-07-02-inbound-motion-deviation-map.md. 19 new gate tests + parser coverage; full suite 3276 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fb3ee0544a
commit
cb74e64343
9 changed files with 464 additions and 18 deletions
175
tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs
Normal file
175
tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using AcDream.Core.Physics;
|
||||
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):
|
||||
///
|
||||
/// <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 MotionSequenceGateTests
|
||||
{
|
||||
// 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, MotionSequenceGate.IsNewer(oldStamp, newStamp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstEvent_WithFreshMovementSequence_IsAccepted()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
Assert.True(gate.TryAcceptMovementEvent(instanceSeq: 0, movementSeq: 1, serverControlSeq: 0));
|
||||
}
|
||||
|
||||
[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 MotionSequenceGate();
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 5, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleMovementSequence_IsDropped_ThenNewerAccepted()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
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 MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 0, movementSeq: 0xFFFD, serverControlSeq: 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 MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
|
||||
Assert.False(gate.TryAcceptMovementEvent(3, 0x8FFF, 2)); // pre-spawn straggler
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reseed_WithOlderStamps_DoesNotRegress()
|
||||
{
|
||||
// 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);
|
||||
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
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 0x9002, 2));
|
||||
|
||||
gate.Seed(instanceSeq: 4, movementSeq: 0x9010, serverControlSeq: 2); // genuine re-create
|
||||
Assert.False(gate.TryAcceptMovementEvent(3, 0x9011, 2)); // old incarnation stale
|
||||
Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2));
|
||||
}
|
||||
|
||||
[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 MotionSequenceGate();
|
||||
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 MotionSequenceGate();
|
||||
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 MotionSequenceGate();
|
||||
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()
|
||||
{
|
||||
// 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();
|
||||
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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceCompare_WrapsAroundTheU16Seam()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 0xFFFE, movementSeq: 0, serverControlSeq: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0));
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // instance 2 newer than 0xFFFE by wrap
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue