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

@ -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
{