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

@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 2. Adaptation (AD) — 31 rows ## 2. Adaptation (AD) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -95,6 +95,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | | AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) |
--- ---
@ -203,7 +204,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | | TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
| TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), but the outbound caller builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91` | Discrete client-initiated motions (D2) not wired yet; packer-ready, runtime emission is Phase 2+ | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) | | TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |

View file

@ -382,6 +382,16 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new(); 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> /// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of /// Per-remote-entity physics + motion stack — verbatim application of
/// retail's client-side motion pipeline to every remote. Mirrors /// retail's client-side motion pipeline to every remote. Mirrors
@ -3010,6 +3020,18 @@ public sealed class GameWindow : IDisposable
{ {
_liveSpawnReceived++; _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 // De-dup: the server re-sends CreateObject for the same guid in
// several situations (visibility refresh, landblock crossing, // several situations (visibility refresh, landblock crossing,
// appearance update). Without cleanup the OLD copy remains in // 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) 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) if (RemoveLiveEntityByServerGuid(delete.Guid)
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") && 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 (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) 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 // #39 (2026-05-06): stamp the per-remote LastUMTime so the
// UP-velocity fallback path in ApplyServerControlledVelocityCycle // UP-velocity fallback path in ApplyServerControlledVelocityCycle
// can skip refinement while a UM is fresh. UMs are authoritative // 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 TeleportSequence = 0,
ushort ServerControlSequence = 0, ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 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? PhysicsState = null,
uint? ObjectDescriptionFlags = null, uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the // 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; if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
// 9 sequence timestamps, always present at end of PhysicsData. // 9 sequence timestamps, always present at end of PhysicsData.
// Indices per holtburger: 0=position, 4=teleport, 5=serverControl, // PhysicsTimeStamp enum order (acclient.h:6084; ACE
// 6=forcePosition, 8=instance. // 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(); if (body.Length - pos < 9 * 2) return PartialResult();
var seqSpan = body.Slice(pos, 9 * 2); var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 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 teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2)); ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2)); ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
@ -847,6 +852,7 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts, return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
physicsState, objectDescriptionFlags, physicsState, objectDescriptionFlags,
friction, elasticity, friction, elasticity,
IconId: iconId, IconId: iconId,

View file

@ -57,10 +57,21 @@ public static class UpdateMotion
/// command is nullable because the <c>ForwardCommand</c> flag may be /// command is nullable because the <c>ForwardCommand</c> flag may be
/// unset in the InterpretedMotionState; the stance is always present /// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance"). /// (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> /// </summary>
public readonly record struct Parsed( public readonly record struct Parsed(
uint Guid, uint Guid,
CreateObject.ServerMotionState MotionState); CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary> /// <summary>
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must /// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
@ -82,8 +93,11 @@ public static class UpdateMotion
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4; 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; if (body.Length - pos < 2) return null;
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2; pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence, // MovementData header: u16 movementSequence, u16 serverControlSequence,
@ -102,8 +116,18 @@ public static class UpdateMotion
// Previous version mistakenly reserved 8 bytes here, which shifted // Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion // every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword). // 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; 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 // movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null; if (body.Length - pos < 4) return null;
@ -139,7 +163,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path. // MovementInvalid branch, just reached via the header'd path.
// Includes the Commands list (MotionItem[]) that carries // Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand. // 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)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4; pos += 4;
uint flags = packed & 0x7Fu; uint flags = packed & 0x7Fu;
@ -162,13 +186,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0) 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)); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2; pos += 2;
} }
if ((flags & 0x2u) != 0) 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)); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2; pos += 2;
} }
@ -244,7 +268,8 @@ public static class UpdateMotion
moveToParameters, moveToParameters,
moveToSpeed, moveToSpeed,
moveToRunRate, moveToRunRate,
moveToPath)); moveToPath),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
} }
catch catch
{ {

View file

@ -109,7 +109,13 @@ public sealed class WorldSession : IDisposable
uint? Priority = null, uint? Priority = null,
int? Structure = null, int? Structure = null,
int? MaxStructure = 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> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned; public event Action<EntitySpawn>? EntitySpawned;
@ -131,7 +137,11 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public readonly record struct EntityMotionUpdate( public readonly record struct EntityMotionUpdate(
uint Guid, uint Guid,
CreateObject.ServerMotionState MotionState); CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary> /// <summary>
/// Fires when the session parses a 0xF74C UpdateMotion game message. /// Fires when the session parses a 0xF74C UpdateMotion game message.
@ -825,7 +835,10 @@ public sealed class WorldSession : IDisposable
parsed.Value.Priority, parsed.Value.Priority,
parsed.Value.Structure, parsed.Value.Structure,
parsed.Value.MaxStructure, 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) else if (op == DeleteObject.Opcode)
@ -862,7 +875,11 @@ public sealed class WorldSession : IDisposable
{ {
MotionUpdated?.Invoke(new EntityMotionUpdate( MotionUpdated?.Invoke(new EntityMotionUpdate(
motion.Value.Guid, 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) 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;
}
}

View file

@ -424,6 +424,21 @@ public sealed class CreateObjectTests
Assert.Equal(7.5f, p.Workmanship); Assert.Equal(7.5f, p.Workmanship);
} }
[Fact]
public void TryParse_MovementSequence_SurfacedFromTimestampBlock()
{
// L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block
// is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds
// MotionSequenceGate's MOVEMENT_TS so post-spawn UpdateMotion events
// are judged against the entity's live sequence, not zero.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000030u, name: "Runner", itemType: 0x10u,
movementSeq: 0x9000);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((ushort)0x9000, parsed!.Value.MovementSequence);
}
[Fact] [Fact]
public void TryParse_MidTailFieldsSet_StillReachesIconOverlay() public void TryParse_MidTailFieldsSet_StillReachesIconOverlay()
{ {
@ -467,7 +482,8 @@ public sealed class CreateObjectTests
uint? validLocations = null, uint? validLocations = null,
uint? currentWieldedLocation = null, uint? currentWieldedLocation = null,
uint? priority = null, uint? priority = null,
float? workmanship = null) float? workmanship = null,
ushort movementSeq = 0)
{ {
var bytes = new List<byte>(); var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode); WriteU32(bytes, CreateObject.Opcode);
@ -479,11 +495,12 @@ public sealed class CreateObjectTests
bytes.Add(0); bytes.Add(0);
bytes.Add(0); bytes.Add(0);
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps. // PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps
// (PhysicsTimeStamp enum order; index 1 = ObjectMovement).
WriteU32(bytes, 0); WriteU32(bytes, 0);
WriteU32(bytes, physicsState); WriteU32(bytes, physicsState);
for (int i = 0; i < 9; i++) for (int i = 0; i < 9; i++)
WriteU16(bytes, 0); WriteU16(bytes, i == 1 ? movementSeq : (ushort)0);
Align4(bytes); Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject. // Fixed WeenieHeader prefix per ACE SerializeCreateObject.

View file

@ -303,6 +303,37 @@ public class UpdateMotionTests
Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed); Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
} }
[Fact]
public void ParsesSequenceNumbersAndAutonomyFlag()
{
// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag must
// survive parsing — retail gates every 0xF74C on them
// (INSTANCE_TS at dispatch, MOVEMENT_TS + SERVER_CONTROLLED_MOVE_TS
// in CPhysics::SetObjectMovement 0x00509690, which also stores
// last_move_was_autonomous).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x50001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0102); p += 2; // instanceSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0304); p += 2; // movementSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0506); p += 2; // serverControlSeq
body[p++] = 1; // isAutonomous
p += 1; // Align(4) pad
body[p++] = 0; // movementType = Invalid
body[p++] = 0; // motionFlags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // outer stance
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x0102, result!.Value.InstanceSequence);
Assert.Equal((ushort)0x0304, result.Value.MovementSequence);
Assert.Equal((ushort)0x0506, result.Value.ServerControlSequence);
Assert.True(result.Value.IsAutonomous);
}
[Fact] [Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin() public void ParsesMoveToObjectTargetGuidAndOrigin()
{ {

View 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
}
}