diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f182d51a..19de8c4e 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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) | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index adfcbb96..9b711550 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -382,6 +382,16 @@ public sealed class GameWindow : IDisposable /// private readonly Dictionary _remoteDeadReckon = new(); + /// + /// 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 , consulted at the + /// top of , dropped with the entity in + /// . + /// + private readonly Dictionary _motionSequenceGates = new(); + /// /// 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 diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index b79546ed..168f1662 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -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, diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 87562815..97e215df 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -57,10 +57,21 @@ public static class UpdateMotion /// command is nullable because the ForwardCommand flag may be /// unset in the InterpretedMotionState; the stance is always present /// (even if 0, meaning "no specific stance"). + /// + /// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are + /// exposed for the retail gate ( + /// MotionSequenceGate) — retail compares them against + /// update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS] + /// and stores last_move_was_autonomous + /// (CPhysics::SetObjectMovement 0x00509690). /// public readonly record struct Parsed( uint Guid, - CreateObject.ServerMotionState MotionState); + CreateObject.ServerMotionState MotionState, + ushort InstanceSequence, + ushort MovementSequence, + ushort ServerControlSequence, + bool IsAutonomous); /// /// Parse a reassembled UpdateMotion 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 { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index b51fae9d..44e2a55d 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -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); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -131,7 +137,11 @@ public sealed class WorldSession : IDisposable /// public readonly record struct EntityMotionUpdate( uint Guid, - CreateObject.ServerMotionState MotionState); + CreateObject.ServerMotionState MotionState, + ushort InstanceSequence, + ushort MovementSequence, + ushort ServerControlSequence, + bool IsAutonomous); /// /// 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) diff --git a/src/AcDream.Core/Physics/MotionSequenceGate.cs b/src/AcDream.Core/Physics/MotionSequenceGate.cs new file mode 100644 index 00000000..55461768 --- /dev/null +++ b/src/AcDream.Core/Physics/MotionSequenceGate.cs @@ -0,0 +1,119 @@ +using System; + +namespace AcDream.Core.Physics; + +/// +/// Per-entity staleness gate for inbound movement events (0xF74C), ported +/// from retail (L.2g S1, closes deviation DEV-6 for the UM path). +/// +/// Retail keeps a update_times[NUM_PHYSICS_TS] array of u16 +/// stamps on every CPhysicsObj (acclient.h:6084 — +/// PhysicsTimeStamp) and rejects out-of-order network events with a +/// wraparound-aware compare. Three of those stamps gate movement events: +/// +/// +/// INSTANCE_TS (8) — checked at dispatch +/// (ACSmartBox::DispatchSmartBoxEvent 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 (SmartBox::QueueBlobForObject) until that object +/// version exists; acdream adopts-and-applies instead — register row +/// AD-32 records the divergence. +/// MOVEMENT_TS (1)CPhysics::SetObjectMovement +/// (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. +/// SERVER_CONTROLLED_MOVE_TS (5) — 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. +/// +/// +/// The compare itself is CPhysicsObj::is_newer (0x00451ad0); +/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows +/// ACE's verbatim PhysicsObj.is_newer (PhysicsObj.cs:2853-2859), +/// cross-checked against the decomp's branch structure. +/// +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; + + /// + /// CPhysicsObj::is_newer (0x00451ad0): true when + /// is newer than + /// under u16 wraparound — when the absolute difference exceeds 0x7fff + /// the numerically SMALLER value is the newer one (the counter wrapped). + /// + public static bool IsNewer(ushort oldStamp, ushort newStamp) + { + if (Math.Abs(newStamp - oldStamp) > short.MaxValue) + return newStamp < oldStamp; + return oldStamp < newStamp; + } + + /// + /// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp + /// block. Retail initializes update_times wholesale from the 9 + /// u16s at the tail of PhysicsDesc (ACE + /// WorldObject_Networking.cs:411-420 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. + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 58a5a017..d7101022 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -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(); 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. diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 09f9eb96..a33158e2 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs new file mode 100644 index 00000000..30430faf --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs @@ -0,0 +1,175 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Covers — the inbound movement-event +/// staleness gate ported from retail (L.2g S1, deviation DEV-6): +/// +/// +/// CPhysicsObj::is_newer (0x00451ad0) — the wraparound u16 +/// timestamp compare (ACE PhysicsObj.is_newer confirms the +/// semantics; the BN pseudo-C return values are setcc-garbled). +/// The 0xF74C dispatch INSTANCE_TS gate +/// (ACSmartBox::DispatchSmartBoxEvent, +/// acclient_2013_pseudo_c.txt:357214-357239): stale incarnation → drop +/// BEFORE any movement stamp is touched. +/// CPhysics::SetObjectMovement (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). +/// +/// +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 + } +}