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:
Erik 2026-07-02 15:44:06 +02:00
parent fb3ee0544a
commit cb74e64343
9 changed files with 464 additions and 18 deletions

View file

@ -382,6 +382,16 @@ public sealed class GameWindow : IDisposable
/// </summary>
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
/// <summary>
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
/// keyed by server guid. Retail keeps these stamps on CPhysicsObj
/// (update_times); seeded from CreateObject's PhysicsDesc timestamp
/// block in <see cref="OnLiveEntitySpawnedLocked"/>, consulted at the
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
/// <see cref="OnLiveEntityDeleted"/>.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.Physics.MotionSequenceGate> _motionSequenceGates = new();
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
/// retail's client-side motion pipeline to every remote. Mirrors
@ -3010,6 +3020,18 @@ public sealed class GameWindow : IDisposable
{
_liveSpawnReceived++;
// L.2g S1 (DEV-6): seed the movement-event staleness gate from the
// CreateObject PhysicsDesc timestamp block, as retail seeds
// update_times at object creation. Seed() adopts wholesale on first
// sight and advance-only afterward, so the #138 rehydrate replay of
// a RETAINED spawn through this handler cannot regress live stamps.
if (!_motionSequenceGates.TryGetValue(spawn.Guid, out var seqGate))
{
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
_motionSequenceGates[spawn.Guid] = seqGate;
}
seqGate.Seed(spawn.InstanceSequence, spawn.MovementSequence, spawn.ServerControlSequence);
// De-dup: the server re-sends CreateObject for the same guid in
// several situations (visibility refresh, landblock crossing,
// appearance update). Without cleanup the OLD copy remains in
@ -3882,6 +3904,11 @@ public sealed class GameWindow : IDisposable
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{
// L.2g S1: drop the staleness gate with the entity — a subsequent
// re-create adopts fresh stamps from its CreateObject (retail's
// update_times die with the CPhysicsObj).
_motionSequenceGates.Remove(delete.Guid);
if (RemoveLiveEntityByServerGuid(delete.Guid)
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
@ -4233,6 +4260,34 @@ public sealed class GameWindow : IDisposable
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
// L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
// Retail drops stale/duplicate/superseded movement events at
// DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
// CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
// straggler re-applies an old gait or un-stops a stop.
if (!_motionSequenceGates.TryGetValue(update.Guid, out var seqGate))
{
// UM for an entity whose CreateObject we never parsed (rare —
// the entity lookup above implies a spawn). Adopt-on-first-seed
// keeps the gate correct from this event onward.
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
_motionSequenceGates[update.Guid] = seqGate;
seqGate.Seed(update.InstanceSequence, update.MovementSequence, update.ServerControlSequence);
}
else if (!seqGate.TryAcceptMovementEvent(
update.InstanceSequence, update.MovementSequence, update.ServerControlSequence))
{
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
{
Console.WriteLine(
$"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} "
+ $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped");
}
return;
}
// #39 (2026-05-06): stamp the per-remote LastUMTime so the
// UP-velocity fallback path in ApplyServerControlledVelocityCycle
// can skip refinement while a UM is fresh. UMs are authoritative

View file

@ -119,6 +119,9 @@ public static class CreateObject
ushort TeleportSequence = 0,
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
@ -531,11 +534,13 @@ public static class CreateObject
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
// 9 sequence timestamps, always present at end of PhysicsData.
// Indices per holtburger: 0=position, 4=teleport, 5=serverControl,
// 6=forcePosition, 8=instance.
// PhysicsTimeStamp enum order (acclient.h:6084; ACE
// WorldObject_Networking.cs:411-420): 0=position, 1=movement,
// 4=teleport, 5=serverControl, 6=forcePosition, 8=instance.
if (body.Length - pos < 9 * 2) return PartialResult();
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
@ -847,6 +852,7 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,

View file

@ -57,10 +57,21 @@ public static class UpdateMotion
/// command is nullable because the <c>ForwardCommand</c> flag may be
/// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance").
///
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
/// <c>MotionSequenceGate</c>) — retail compares them against
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
/// and stores <c>last_move_was_autonomous</c>
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState);
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary>
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
@ -82,8 +93,11 @@ public static class UpdateMotion
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
// ObjectInstance sequence (u16) — tracked but not used for pose.
// ObjectInstance sequence (u16) — retail's dispatch-level
// INSTANCE_TS staleness gate compares this against the object's
// known incarnation (DispatchSmartBoxEvent case 0xF74C).
if (body.Length - pos < 2) return null;
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence,
@ -102,8 +116,18 @@ public static class UpdateMotion
// Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword).
//
// L.2g S1 (DEV-6): the header fields feed retail's
// CPhysics::SetObjectMovement staleness gates + the
// last_move_was_autonomous store, so parse them instead of
// skipping.
if (body.Length - pos < 6) return null;
pos += 6;
ushort movementSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
ushort serverControlSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
bool isAutonomous = body[pos] != 0;
pos += 2; // u8 isAutonomous + Align(4) pad byte
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
@ -139,7 +163,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path.
// Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand.
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
@ -162,13 +186,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((flags & 0x2u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
@ -244,7 +268,8 @@ public static class UpdateMotion
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath));
moveToPath),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
catch
{

View file

@ -109,7 +109,13 @@ public sealed class WorldSession : IDisposable
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? Workmanship = null,
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
// per-entity MotionSequenceGate (retail update_times INSTANCE_TS /
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -131,7 +137,11 @@ public sealed class WorldSession : IDisposable
/// </summary>
public readonly record struct EntityMotionUpdate(
uint Guid,
CreateObject.ServerMotionState MotionState);
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary>
/// Fires when the session parses a 0xF74C UpdateMotion game message.
@ -825,7 +835,10 @@ public sealed class WorldSession : IDisposable
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship));
parsed.Value.Workmanship,
InstanceSequence: parsed.Value.InstanceSequence,
MovementSequence: parsed.Value.MovementSequence,
ServerControlSequence: parsed.Value.ServerControlSequence));
}
}
else if (op == DeleteObject.Opcode)
@ -862,7 +875,11 @@ public sealed class WorldSession : IDisposable
{
MotionUpdated?.Invoke(new EntityMotionUpdate(
motion.Value.Guid,
motion.Value.MotionState));
motion.Value.MotionState,
motion.Value.InstanceSequence,
motion.Value.MovementSequence,
motion.Value.ServerControlSequence,
motion.Value.IsAutonomous));
}
}
else if (op == UpdatePosition.Opcode)

View file

@ -0,0 +1,119 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
///
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
///
/// <list type="bullet">
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
/// OLDER object incarnation than the one we know is dropped before any
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
/// version exists; acdream adopts-and-applies instead — register row
/// AD-32 records the divergence.</item>
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
/// when its movement sequence is STRICTLY newer than the stored stamp
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
/// server-control gate — a movement sequence is consumed even when the
/// event is subsequently dropped for stale server control.</item>
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
/// dropped when the STORED server-control stamp is strictly newer than
/// the incoming one (a newer server-control era has already been seen);
/// equal passes and re-stamps.</item>
/// </list>
///
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
/// cross-checked against the decomp's branch structure.</para>
/// </summary>
public sealed class MotionSequenceGate
{
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
private bool _seeded;
/// <summary>
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
/// the numerically SMALLER value is the newer one (the counter wrapped).
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
return newStamp < oldStamp;
return oldStamp < newStamp;
}
/// <summary>
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
/// block. Retail initializes <c>update_times</c> wholesale from the 9
/// u16s at the tail of PhysicsDesc (ACE
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
/// PhysicsTimeStamp enum order); without this, an entity whose movement
/// sequence is already past 0x8000 at spawn would have every subsequent
/// movement event judged stale against a zero stamp.
///
/// <para>The first Seed adopts the stamps wholesale (fresh object);
/// subsequent Seeds only move stamps FORWARD. This makes the #138
/// rehydrate path (which replays a RETAINED CreateObject through the
/// spawn handler) a no-op instead of a stamp regression, while a genuine
/// wire re-create (server sequences only advance) still seeds correctly.
/// Retail has no equivalent replay path — its objects keep their stamps
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
/// </summary>
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
if (!_seeded)
{
_instanceTs = instanceSeq;
_movementTs = movementSeq;
_serverControlTs = serverControlSeq;
_seeded = true;
return;
}
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
}
/// <summary>
/// Apply the retail three-stamp gate to an inbound movement event.
/// Returns true when the event should be applied (stamps updated),
/// false when it must be dropped as stale/duplicate/superseded.
/// </summary>
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
// Dispatch-level incarnation gate — runs before any other stamp is
// touched (retail drops at DispatchSmartBoxEvent, never reaching
// SetObjectMovement).
if (IsNewer(instanceSeq, _instanceTs))
return false;
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
// Gate 1: movement sequence must be strictly newer.
if (!IsNewer(_movementTs, movementSeq))
return false;
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
// Gate 2: drop when a newer server-control era has already been seen.
if (IsNewer(serverControlSeq, _serverControlTs))
return false;
_serverControlTs = serverControlSeq;
return true;
}
}