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
|
|
@ -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 |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -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-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-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-24 | RawMotionState action list always empty at runtime — the packer now emits `num_actions` (bits 11–15) + 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-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-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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal file
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -424,6 +424,21 @@ public sealed class CreateObjectTests
|
|||
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]
|
||||
public void TryParse_MidTailFieldsSet_StillReachesIconOverlay()
|
||||
{
|
||||
|
|
@ -467,7 +482,8 @@ public sealed class CreateObjectTests
|
|||
uint? validLocations = null,
|
||||
uint? currentWieldedLocation = null,
|
||||
uint? priority = null,
|
||||
float? workmanship = null)
|
||||
float? workmanship = null,
|
||||
ushort movementSeq = 0)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
WriteU32(bytes, CreateObject.Opcode);
|
||||
|
|
@ -479,11 +495,12 @@ public sealed class CreateObjectTests
|
|||
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, physicsState);
|
||||
for (int i = 0; i < 9; i++)
|
||||
WriteU16(bytes, 0);
|
||||
WriteU16(bytes, i == 1 ? movementSeq : (ushort)0);
|
||||
Align4(bytes);
|
||||
|
||||
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
|
||||
|
|
|
|||
|
|
@ -303,6 +303,37 @@ public class UpdateMotionTests
|
|||
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]
|
||||
public void ParsesMoveToObjectTargetGuidAndOrigin()
|
||||
{
|
||||
|
|
|
|||
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