From afafefd71fb34599dbe6b05d97d3af7465e13c30 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:26:55 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(anim):=20MultiplyCyclicFramerate=20?= =?UTF-8?q?=E2=80=94=20retail=20mid-cycle=20speed=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server broadcasts a mid-run UpdateMotion with a different ForwardSpeed (e.g. the player's RunRate changes due to stamina / skill update), acdream must NOT restart the cycle — that would reset the footstep cursor and look like a visible twitch. Retail handles this via Sequence.multiply_cyclic_animation_framerate (ACE references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287), which walks the cyclic tail of the queue and scales each node's framerate by newSpeed / oldSpeed. MotionTable.change_cycle_speed (MotionTable.cs L372-L379) is the caller from the same-motion path in GetObjectSequence (L132-L139). This commit: 1. Adds AnimNode.MultiplyFramerate(factor) — scales a single node's framerate. Retail also swapped StartFrame↔EndFrame for negative factors; acdream keeps StartFrame ≤ EndFrame as an invariant and encodes direction via Framerate sign (see existing comment in LoadAnimNode), so we only scale. Valid because callers only ever pass positive factors from UpdateMotion ForwardSpeed. 2. Adds AnimationSequencer.MultiplyCyclicFramerate(factor) — walks _firstCyclic through the tail and calls node.MultiplyFramerate(factor). Also scales each node's Velocity and Omega by the same factor so CurrentVelocity / CurrentOmega stay aligned with playback — matches ACE's subtract_motion + combine_motion pair in change_cycle_speed. 3. Adds AnimationSequencer.CurrentSpeedMod public property — starts at 1.0, updated by SetCycle on both restart and mid-cycle rescale. 4. Adds a speed-change fast-path to SetCycle: when the (style, motion) pair matches the current cycle and signs agree, MultiplyCyclicFramerate(newSpeed/oldSpeed) is called instead of rebuilding the queue — the cursor stays where it is and the animation continues at the new rate. 5. Wires InterpretedMotionState.ForwardSpeed from UpdateMotion through to SetCycle in OnLiveMotionUpdated. ACE omits the ForwardSpeed flag when speed == 1.0 (InterpretedMotionState.cs:101-103), so we default missing/zero values to 1.0. Tests: 4 new sequencer tests covering MultiplyCyclicFramerate, cursor preservation across speed changes, the same-motion-different-speed fast-path, and the same-motion-same-speed no-op guard. 632 tests green (was 628). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 +- .../Physics/AnimationSequencer.cs | 86 +++++++++- .../Physics/AnimationSequencerTests.cs | 150 ++++++++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b7a7d41..9dfe8ce 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1399,13 +1399,26 @@ public sealed class GameWindow : IDisposable fullMotion = ae.Sequencer.CurrentMotion; } + // ForwardSpeed from the InterpretedMotionState (flag 0x10). + // ACE omits this field when speed == 1.0 (only sets the flag + // when ForwardSpeed != 1.0 — see InterpretedMotionState.cs + // BuildMovementFlags L101-L103). So: + // - omitted / 0 → 1.0 (normal speed) + // - present → retail server-broadcast speedMod + // + // The sequencer's SetCycle fast-paths identical (style, motion) + // pairs and calls MultiplyCyclicFramerate when only speedMod + // changed — keeping the loop smooth during a mid-run RunRate + // broadcast. + float speedMod = update.MotionState.ForwardSpeed is { } fs && fs > 0f ? fs : 1f; + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" && update.Guid != _playerServerGuid) Console.WriteLine( - $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8})"); + $"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})"); // No-op if same; the sequencer's fast path guards against that. - ae.Sequencer.SetCycle(fullStyle, fullMotion); + ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); return; } diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index d9ba4ca..61d95ee 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -121,6 +121,26 @@ internal sealed class AnimNode Omega = omega; } + // ── FUN_005267E0 — multiply_framerate ───────────────────────────────── + // Scales this node's framerate by a factor. Used by + // AnimationSequencer.MultiplyCyclicFramerate to retarget an already-queued + // cyclic animation at a new playback speed without restarting. + // + // Retail's implementation additionally swapped StartFrame↔EndFrame for a + // negative factor (so the forward-playback advance loop could traverse + // either direction), but acdream's AnimNode keeps StartFrame ≤ EndFrame + // as an invariant and encodes direction purely via Framerate's sign — the + // Advance loop then checks against StartFrame as the lower bound for + // negative delta. So here we only scale. + // + // Mirrors ACE AnimSequenceNode.multiply_framerate / Sequence.cs L277-L287 + // modulo the swap difference. Valid because the callers we care about + // (ForwardSpeed updates from UpdateMotion) only ever pass positive factors. + public void MultiplyFramerate(double factor) + { + Framerate *= factor; + } + // ── FUN_00526880 — GetStartFramePosition ────────────────────────────── // Returns the initial framePosition cursor for this node. // speedScale >= 0 → (double)startFrame @@ -200,6 +220,14 @@ public sealed class AnimationSequencer /// Current cyclic motion command. public uint CurrentMotion { get; private set; } + /// + /// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 + /// and is updated by when the same motion is + /// re-issued with a different speed (which triggers + /// instead of a cycle restart). + /// + public float CurrentSpeedMod { get; private set; } = 1f; + /// /// World-space per-second velocity from the currently active /// (Sequence.Velocity in retail). Zero when no @@ -313,10 +341,26 @@ public sealed class AnimationSequencer break; } - // Fast-path: already playing this exact motion at the same speed. + // Fast-path: already playing this exact motion. + // + // Retail (ACE MotionTable.cs:132-139): when motion == current and + // sign(speedMod) matches, DON'T restart the cycle — just rescale the + // in-flight cyclic-tail's framerate via multiply_cyclic_animation_framerate. + // This keeps the run/walk loop smooth when a new UpdateMotion arrives + // with a different ForwardSpeed (e.g. when the server broadcasts a + // player's updated RunRate mid-step). if (CurrentStyle == style && CurrentMotion == motion && _firstCyclic != null && _queue.Count > 0) + { + if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f + && MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod) + && MathF.Abs(CurrentSpeedMod) > 1e-6f) + { + MultiplyCyclicFramerate(speedMod / CurrentSpeedMod); + CurrentSpeedMod = speedMod; + } return; + } // Resolve transition link (currentSubstate → adjustedMotion). MotionData? linkData = CurrentMotion != 0 @@ -371,6 +415,45 @@ public sealed class AnimationSequencer CurrentStyle = style; CurrentMotion = motion; + CurrentSpeedMod = speedMod; + } + + /// + /// Scale every cyclic node's framerate by , mirroring + /// ACE's Sequence.multiply_cyclic_animation_framerate + /// (references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287, + /// retail decompile FUN_00525CE0). Walks _firstCyclic through + /// the tail of the queue and calls + /// on each. The non-cyclic head (link frames) is untouched — those drain + /// at their original framerate, which matches retail: the sequencer + /// "catches up" the transition before applying the new run speed. + /// + /// + /// Called from when the same (style, motion) pair + /// is re-issued with a different speedMod — for instance, when a remote + /// player's ForwardSpeed changes mid-run. Does NOT restart the animation, + /// so footsteps keep planting where they are. + /// + /// + /// Framerate multiplier (newSpeed / oldSpeed). + public void MultiplyCyclicFramerate(float factor) + { + if (_firstCyclic == null) return; + if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor)) + return; + + for (var node = _firstCyclic; node != null; node = node.Next) + { + node.Value.MultiplyFramerate((double)factor); + // Velocity/Omega carried on the node scale with the framerate, so + // the physics velocity surfaced by CurrentVelocity matches the + // animation playback. (ACE does the same: add_motion sets both + // to the scaled value and multiply_cyclic_animation_framerate is + // preceded by subtract_motion/combine_motion in change_cycle_speed + // to keep them aligned — MotionTable.cs:372-379.) + node.Value.Velocity *= factor; + node.Value.Omega *= factor; + } } /// @@ -654,6 +737,7 @@ public sealed class AnimationSequencer _rootMotionRot = Quaternion.Identity; CurrentStyle = 0; CurrentMotion = 0; + CurrentSpeedMod = 1f; } // ── Private helpers ────────────────────────────────────────────────────── diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 6b278c8..1f9316d 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -979,6 +979,156 @@ public sealed class AnimationSequencerTests Assert.Contains(hooks, h => h is AnimationDoneHook); } + // ── MultiplyCyclicFramerate / speed-mod tracking ───────────────────────── + + [Fact] + public void MultiplyCyclicFramerate_DoublesPlaybackRate() + { + // A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback + // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). + const uint Style = 0x003Du; + const uint Motion = 0x0007u; // RunForward + const uint AnimId = 0x03000401u; + + // Unique per-frame Z so we can tell where the cursor lands. + var anim = new Animation(); + for (int f = 0; f < 10; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = 9, + Framerate = 10f, + }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + + // Halve the playback rate. + seq.MultiplyCyclicFramerate(0.5f); + + // 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5. + seq.Advance(1.0f); + var frames = seq.Advance(0.001f); + Assert.Single(frames); + Assert.InRange(frames[0].Origin.Z, 4f, 6f); + + // Velocity also scales: originally (0,4,0), now (0,2,0). + Assert.Equal(2f, seq.CurrentVelocity.Y, 1); + } + + [Fact] + public void MultiplyCyclicFramerate_PreservesCursorPosition() + { + // Changing speed mid-cycle must NOT reset the frame cursor — the + // animation keeps playing from where it was, just faster/slower. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000402u; + + var anim = new Animation(); + for (int f = 0; f < 10; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.Advance(0.3f); // cursor ~ frame 3 + double before = GetFramePosition(seq); + + seq.MultiplyCyclicFramerate(2.0f); + double after = GetFramePosition(seq); + + Assert.Equal(before, after, 5); + } + + [Fact] + public void SetCycle_SameMotionDifferentSpeed_RescalesInPlace() + { + // Re-issuing SetCycle with the same motion but a new speedMod must + // NOT reset the cursor — it should call MultiplyCyclicFramerate to + // keep the run loop smooth (retail behavior for a mid-run RunRate + // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000403u; + + var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + seq.Advance(0.3f); + double cursorMid = GetFramePosition(seq); + + Assert.Equal(1f, seq.CurrentSpeedMod, 3); + + // Re-issue with 2× speed — should rescale in place. + seq.SetCycle(Style, Motion, speedMod: 2f); + + Assert.Equal(2f, seq.CurrentSpeedMod, 3); + Assert.Equal(cursorMid, GetFramePosition(seq), 5); + } + + [Fact] + public void SetCycle_SameMotionSameSpeed_StaysNoOp() + { + // Guard: the new speed-path must not break the classic + // "identical call = no state change" behavior. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000404u; + + var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1.5f); + seq.Advance(0.2f); + double before = GetFramePosition(seq); + + seq.SetCycle(Style, Motion, speedMod: 1.5f); + + Assert.Equal(before, GetFramePosition(seq), 5); + Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// Expose _framePosition (double) via reflection (test-only). From b7a9322b400a555ce5bd3221afd21df817a53646 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:29:56 +0200 Subject: [PATCH 02/10] feat(anim): dead-reckoning remote entity positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: remote characters stutter-hop between UpdatePosition broadcasts (typical 100-200ms interval), looking lagging-forward during continuous motion. The retail client hides this gap by integrating velocity forward each tick — apply_current_movement in chunk_00520000.c L7132-L7189, mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs. Strategy: 1. RemoteDeadReckonState per remote entity tracks the last authoritative server position + rotation, an EMA-smoothed observed velocity from position deltas, and any server-supplied HasVelocity vector. 2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity to the server position, then update the dead-reckon state. The observed-velocity is a 50/50 EMA against the running average so a single jitter sample doesn't blow out the velocity. 3. TickAnimations: each tick, for every remote entity in a locomotion cycle, integrate Entity.Position += worldVelocity * dt. World velocity is pulled in priority order: - Sequencer's MotionData.Velocity rotated by Entity.Rotation (the primary source; matches MotionData's "world-space on the object" convention per r03 §1.3) - Server-supplied HasVelocity from UpdatePosition (already world-space) - EMA-observed position-delta velocity (fallback for NPC motion tables with HasVelocity=0) 4. Cap: if the predicted position drifts more than velocity × DeadReckonMaxPredictSeconds (1.0s) from the last server position, clamp back toward the server. This prevents runaway when sequencer velocity and server reality disagree (e.g. server rubber-banding). Result: remote chars now move smoothly between position updates, matching the retail client's visual feel. When UpdatePosition arrives the entity snaps to the authoritative position and the dead-reckon origin resets, so there's no accumulating drift. Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying that the sequencer's CurrentVelocity accurately reflects speedMod changes across both SetCycle's rebuild path and its rescale path. Combined with the existing MultiplyCyclicFramerate tests, this validates the downstream-visible velocity surface the dead-reckoner reads. 633 tests green (was 632). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 176 +++++++++++++++++- .../Physics/AnimationSequencerTests.cs | 45 +++++ 2 files changed, 212 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9dfe8ce..a5ba3f4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,6 +150,54 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _remoteLastMove = new(); + /// + /// Per-remote-entity dead-reckoning state for smoothing between server + /// UpdatePosition broadcasts. Without this, remote characters teleport + /// every ~100–200 ms when the server pushes a new position (the retail + /// client hides the gap by integrating CMotionInterp-surfaced + /// velocity forward each tick — see chunk_00520000.c + /// apply_current_movement L7132-L7189 and holtburger's + /// spatial/physics.rs::project_pose_by_velocity). + /// + /// + /// Each entry records the last authoritative server position + time + a + /// measured velocity inferred from the delta between consecutive + /// UpdatePositions. The client's per-tick integrator uses the + /// sequencer's CurrentVelocity (rotated into world space by the + /// entity's orientation) as the primary source and falls back to the + /// inferred velocity when the motion table doesn't carry one (e.g. NPC + /// motion tables with HasVelocity=0). + /// + /// + private readonly Dictionary _remoteDeadReckon = new(); + + private sealed class RemoteDeadReckonState + { + /// Last server-authoritative world position. + public System.Numerics.Vector3 LastServerPos; + /// When that last server position arrived (UTC). + public System.DateTime LastServerPosTime; + /// Last server-authoritative world rotation. + public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity; + /// + /// Most recently observed position-delta-based world velocity, used + /// as fallback when the sequencer has no CurrentVelocity. Computed + /// as (pos_new - pos_old) / dt across consecutive UpdatePositions. + /// + public System.Numerics.Vector3 ObservedVelocity; + /// Server-supplied world velocity from UpdatePosition (HasVelocity flag). + public System.Numerics.Vector3? ServerVelocity; + } + + /// + /// Soft-snap window in seconds: after an UpdatePosition arrives for a + /// remote entity, dead-reckoning continues but the "origin" for + /// predicted position is the server pos. This matches retail's snap + /// behavior — the server is authoritative, we just interpolate between + /// authoritative samples. + /// + private const float DeadReckonMaxPredictSeconds = 1.0f; + // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. public readonly AcDream.Core.Chat.ChatLog Chat = new(); @@ -1483,19 +1531,49 @@ public sealed class GameWindow : IDisposable // timestamp when position moved MEANINGFULLY (> 0.05m). Updates // that report the same position keep the old Time, so the // TickAnimations check can see when motion last changed. + // + // Also populate the dead-reckon state so TickAnimations can + // integrate velocity between server updates and avoid teleport jitter. + // Observed-velocity is computed from the position delta across + // consecutive updates — this is the fallback when the motion table's + // MotionData.Velocity is zero (NPCs without HasVelocity). if (update.Guid != _playerServerGuid) { + var now = System.DateTime.UtcNow; if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) { float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); if (moveDist > 0.05f) - _remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow); + _remoteLastMove[update.Guid] = (worldPos, now); // else: leave old entry so "Time" = last real movement time } else { - _remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow); + _remoteLastMove[update.Guid] = (worldPos, now); } + + // Dead-reckon state: accumulate observed world-space velocity. + if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState)) + { + drState = new RemoteDeadReckonState(); + _remoteDeadReckon[update.Guid] = drState; + } + else + { + float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds; + if (dtSec > 0.01f && dtSec < 1.0f) + { + // EMA-smooth the observed velocity so one-off snaps don't + // overwrite the running average. alpha=0.5 converges fast + // but resists single-frame noise. + var observed = (worldPos - drState.LastServerPos) / dtSec; + drState.ObservedVelocity = 0.5f * drState.ObservedVelocity + 0.5f * observed; + } + } + drState.LastServerPos = worldPos; + drState.LastServerRot = rot; + drState.LastServerPosTime = now; + drState.ServerVelocity = update.Velocity; } // Phase B.3: portal-space arrival detection. @@ -3154,6 +3232,15 @@ public sealed class GameWindow : IDisposable { var ae = kv.Value; + // Locate the server guid for this entity once per tick — needed + // for both stop-detection and dead-reckoning. O(N) reverse + // lookup; for player populations < 100 the cost is negligible. + uint serverGuid = 0; + foreach (var esg in _entitiesByServerGuid) + { + if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } + } + // ── Remote stop-detection: if this entity's sequencer is in a // locomotion cycle and their position hasn't changed in >400ms, // the retail player stopped moving. Swap them to Ready. This @@ -3166,13 +3253,6 @@ public sealed class GameWindow : IDisposable || motionLo == 0x07 // RunForward || motionLo == 0x0F // SideStepRight || motionLo == 0x10; // SideStepLeft - // Locate the server guid for this entity (reverse lookup). - // Skip the player's own entity — we drive our own anim locally. - uint serverGuid = 0; - foreach (var esg in _entitiesByServerGuid) - { - if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } - } if (inLocomotion && serverGuid != 0 && serverGuid != _playerServerGuid @@ -3187,6 +3267,84 @@ public sealed class GameWindow : IDisposable } } + // ── Dead-reckoning: smooth position between UpdatePosition bursts. + // The server broadcasts UpdatePosition at ~5-10Hz for distant + // entities; without integration, remote chars jitter-hop between + // samples. Each tick we advance entity.Position by the + // sequencer's current velocity (rotated into world space by the + // entity's facing) — matching the retail client's + // apply_current_movement (chunk_00520000.c L7132-L7189) and + // holtburger's project_pose_by_velocity. + // + // The cap on predict-distance from the last server pos prevents + // runaway when the sequencer's velocity and the server's reality + // disagree (e.g. server is rubber-banding the entity). Retail + // uses a similar clamp at PhysicsObj::IsInterpolationComplete. + if (ae.Sequencer is not null + && serverGuid != 0 + && serverGuid != _playerServerGuid + && _remoteDeadReckon.TryGetValue(serverGuid, out var drState)) + { + System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero; + + // Priority 1: sequencer's MotionData velocity, rotated into + // world space by the entity's orientation. "World space on + // the object" (r03 §1.3) → local vector rotated by entity + // rotation → world space. + var seqVel = ae.Sequencer.CurrentVelocity; + if (seqVel.LengthSquared() > 1e-6f) + { + worldVel = System.Numerics.Vector3.Transform(seqVel, ae.Entity.Rotation); + } + // Priority 2: server-supplied world velocity (HasVelocity flag + // on UpdatePosition). Already world-space; no rotation. + else if (drState.ServerVelocity is { } sv && sv.LengthSquared() > 1e-6f) + { + worldVel = sv; + } + // Priority 3: EMA-observed velocity from position deltas. + // Fallback for NPC motion tables with HasVelocity=0 (dat + // authors didn't encode it). Already world-space. + else if (drState.ObservedVelocity.LengthSquared() > 1e-6f + && (now - drState.LastServerPosTime).TotalMilliseconds < 2000.0) + { + worldVel = drState.ObservedVelocity; + } + + if (worldVel.LengthSquared() > 1e-6f) + { + // Only integrate while the cycle is a locomotion cycle. + // Idle (Ready 0x03) and emotes should stay pinned at the + // last server pos — MotionData for Ready has no velocity + // anyway, but belt + suspenders. + uint mlo = ae.Sequencer.CurrentMotion & 0xFFu; + bool isLocomotion = mlo == 0x05 || mlo == 0x06 + || mlo == 0x07 + || mlo == 0x0F || mlo == 0x10; + if (isLocomotion) + { + var predicted = ae.Entity.Position + worldVel * dt; + // Cap prediction radius around last server pos. Over + // DeadReckonMaxPredictSeconds we must not drift more + // than 1 RunAnimSpeed × run-rate away from server + // truth, so cap at |worldVel| * max time. + float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds; + var fromServer = predicted - drState.LastServerPos; + if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f) + { + // Clamp back toward last server position. + var clamped = drState.LastServerPos + + System.Numerics.Vector3.Normalize(fromServer) * maxDrift; + ae.Entity.Position = clamped; + } + else + { + ae.Entity.Position = predicted; + } + } + } + } + // ── Get per-part (origin, orientation) from either sequencer or legacy ── IReadOnlyList? seqFrames = null; if (ae.Sequencer is not null) diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 1f9316d..717ec2e 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1102,6 +1102,51 @@ public sealed class AnimationSequencerTests Assert.Equal(cursorMid, GetFramePosition(seq), 5); } + [Fact] + public void CurrentVelocity_ScalesWithSpeedMod() + { + // A RunForward motion with MotionData.Velocity = (0,4,0) should + // surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at + // 0.5×. The dead-reckoning integrator in TickAnimations reads + // CurrentVelocity each tick, so this has to be accurate. + const uint Style = 0x003Du; + const uint Motion = 0x0007u; + const uint AnimId = 0x03000405u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + + var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = -1, + Framerate = 10f, + }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 1f); + Assert.Equal(4f, seq.CurrentVelocity.Y, 3); + + // Start a fresh sequencer so the initial SetCycle applies speedMod. + var seq2 = new AnimationSequencer(setup, mt, loader); + seq2.SetCycle(Style, Motion, speedMod: 1.5f); + Assert.Equal(6f, seq2.CurrentVelocity.Y, 3); + + // Same-motion rescale path also updates velocity. + seq2.SetCycle(Style, Motion, speedMod: 0.5f); + Assert.Equal(2f, seq2.CurrentVelocity.Y, 2); + } + [Fact] public void SetCycle_SameMotionSameSpeed_StaysNoOp() { From 3f41872d8889e81b1b201779988b8aa6b2a8a885 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:34:18 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(anim):=20route=20Commands[]=20list?= =?UTF-8?q?=20=E2=80=94=20full=20NPC/monster=20motion=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateMotion's InterpretedMotionState payload includes not just ForwardCommand but a whole Commands[] list of MotionItem entries — each carrying an Action (attack, portal, skill use), Modifier (jump, stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the current cycle. The old parser stopped reading after ForwardSpeed, so emotes/attacks/deaths never reached the sequencer and NPCs just sat in their idle cycle. Three parts: 1. New MotionItem wire record in ServerMotionState — carries Command (u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp), and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs. 2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData now read the full InterpretedMotionState: all 7 flag fields (CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand, ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands × MotionItem tail. The packed u32 encodes flags in low 7 bits and command count in bits 7+ (see ACE InterpretedMotionState.cs:131). 3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand class byte from a 16-bit wire value via a reflection-built lookup of DatReaderWriter.Enums.MotionCommand. Server serializes as u16 (ACE InterpretedMotionState.cs:139) and we need the class to route: - 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote → PlayAction (resolves from Modifiers or Links dict, overlays on current cycle) - 0x40xxxxxx SubState → SetCycle (cycle change) 4. OnLiveMotionUpdated in GameWindow dispatches each command: - SubState class (0x40xxx) → SetCycle (treated same as ForwardCommand) - Action/Modifier/ChatEmote → PlayAction — the link animation plays once then drops back to the current cycle naturally (matches retail's action-queue pattern in CMotionInterp DoInterpretedMotion, decompile FUN_00528F70). Result: NPCs now animate attacks, waves, bows, death throes, and other one-shots that ACE broadcasts via the Commands list rather than the primary ForwardCommand field. Combined with the dead-reckoning + speed- scaling from the prior commits, remote characters look visually correct during the full motion spectrum (idle → walk → run → attack → death). Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full Wave command list parse) + 19 new MotionCommandResolver reconstruction tests covering SubState, Action, and ChatEmote classes. 654 tests green (was 633). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 54 +++++++++++ src/AcDream.Core.Net/Messages/CreateObject.cs | 59 ++++++++++-- src/AcDream.Core.Net/Messages/UpdateMotion.cs | 29 +++++- .../Physics/MotionCommandResolver.cs | 89 +++++++++++++++++++ .../Messages/UpdateMotionTests.cs | 68 ++++++++++++++ .../Physics/MotionCommandResolverTests.cs | 53 +++++++++++ 6 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 src/AcDream.Core/Physics/MotionCommandResolver.cs create mode 100644 tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a5ba3f4..28901ff 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1467,6 +1467,60 @@ public sealed class GameWindow : IDisposable // No-op if same; the sequencer's fast path guards against that. ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod); + + // Route the Commands list — one-shot Actions, Modifiers, and + // ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These + // live in the motion table's Links / Modifiers dicts, not + // Cycles, and are played on top of the current cycle via + // PlayAction which resolves the right dict and interleaves the + // action frames before the cyclic tail. + // + // A typical NPC wave looks like: + // ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}] + // [{0x0003=Ready, ...}] + // Each item runs through PlayAction (for 0x10/0x20 mask) or the + // standard SetCycle path (for 0x40 SubState). We leave SubState + // commands to fall through to the next UpdateMotion; that's how + // retail handles transition sequences (Wave → Ready). + if (update.MotionState.Commands is { Count: > 0 } cmds) + { + foreach (var item in cmds) + { + // Restore the 32-bit MotionCommand from the wire's 16-bit + // truncation by OR-ing class bits. The class is encoded + // in the low byte's high nibble via command ranges: + // 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx) + // 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx) + // 0x0051-0x00A1 — Action class (0x10xx xxxx) + // + // The retail MotionCommand enum carries the class byte in + // bits 24-31. DatReaderWriter's enum values match. For + // broadcasts, servers emit only low 16 bits (ACE + // InterpretedMotionState.cs:139). We reconstruct via a + // range-based lookup. See MotionCommand.generated.cs. + uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command); + if (fullCmd == 0) continue; + + // Action class: play through the link dict then drop back + // to the current cycle. Modifier class: resolve from the + // Modifiers dict and combine on top. SubState: cycle + // change; route through SetCycle so the style-specific + // cycle fallback applies. + uint cls = fullCmd & 0xFF000000u; + if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0 + || cls == 0x12000000u || cls == 0x13000000u) + { + ae.Sequencer.PlayAction(fullCmd, item.Speed); + } + else if ((cls & 0x40000000u) != 0) + { + // Substate in the command list — typically the "and + // then return to Ready" item. Update the cycle. + ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed); + } + // else: Style / UI / Toggle class — not animation-driving. + } + } return; } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index d08008d..6d3e918 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -109,7 +110,27 @@ public static class CreateObject /// Nullified Statue of a Drudge, which is rendered in the wrong pose /// if you only consult the MotionTable's default style. /// - public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null); + public readonly record struct ServerMotionState( + ushort Stance, + ushort? ForwardCommand, + float? ForwardSpeed = null, + IReadOnlyList? Commands = null); + + /// + /// One entry in the InterpretedMotionState's Commands list (MotionItem). + /// The server packs 0..many of these per broadcast: emotes, attacks, + /// and other one-shot motions arrive here, not in ForwardCommand. + /// + /// Wire layout (see ACE Network/Motion/MotionItem.cs): + /// u16 command — low 16 bits of MotionCommand (Action class + /// typically 0x10xx; ChatEmote 0x13xx) + /// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp + /// f32 speed — speedMod for the animation + /// + public readonly record struct MotionItem( + ushort Command, + ushort PackedSequence, + float Speed); /// /// Server instruction to replace the surface texture at @@ -480,6 +501,7 @@ public static class CreateObject ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; // 0 = Invalid is the only union variant we care about for static // entities. Walking/turning entities use the other variants but @@ -488,21 +510,20 @@ public static class CreateObject if (movementType == 0) { // InterpretedMotionState: u32 (flags | numCommands<<7), then - // each present field in flag order. We only care about - // ForwardCommand, so read in order and stop early if we - // can't get that far. + // each present field in flag order. Flag bits (low 7) are + // CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is + // the MotionItem list length that follows after the speed + // fields (see ACE InterpretedMotionState.cs::Write). if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p)); p += 4; uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits + uint numCommands = packed >> 7; // CurrentStyle (0x1) if ((flags & 0x1u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); - // The InterpretedMotionState's CurrentStyle is just a copy - // of MovementData.CurrentStyle per ACE source. Read and - // prefer it as the more specific value. currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } @@ -525,10 +546,32 @@ public static class CreateObject forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); p += 4; } + // SidestepSpeed (0x20) — skip + if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } + // TurnSpeed (0x40) — skip + if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; } + + // Commands list: numCommands × 8-byte MotionItem (u16 cmd + + // u16 packedSeq + f32 speed). One-shot actions, emotes, + // attacks — everything that's NOT a looping cycle change + // arrives here. Cap read at the buffer boundary. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (mv.Length - p < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4)); + p += 8; + commands.Add(new MotionItem(cmd, seq, speed)); + } + } done:; } - return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed); + return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands); } catch { diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 5a74e76..f4f1486 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Collections.Generic; namespace AcDream.Core.Net.Messages; @@ -122,17 +123,19 @@ public static class UpdateMotion ushort? forwardCommand = null; float? forwardSpeed = null; + List? commands = null; if (movementType == 0) { // InterpretedMotionState — same layout as in CreateObject's // MovementInvalid branch, just reached via the header'd path. - // Only ForwardCommand is pulled out; the rest is deliberately - // ignored because the animation system consumes nothing else. + // 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)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; + uint numCommands = packed >> 7; // CurrentStyle (0x1) — prefer the InterpretedMotionState's copy // if present, matching the CreateObject parser's behavior. @@ -161,10 +164,30 @@ public static class UpdateMotion forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } + // SidestepSpeed (0x20) — skip + if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } + // TurnSpeed (0x40) — skip + if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; } + + // Commands list: actions/emotes/attacks. Guard against a + // malformed numCommands by capping at a sane max. + if (numCommands > 0 && numCommands < 1024) + { + commands = new List((int)numCommands); + for (int i = 0; i < numCommands; i++) + { + if (body.Length - pos < 8) break; + ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2)); + float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)); + pos += 8; + commands.Add(new CreateObject.MotionItem(cmd, seq, speed)); + } + } done:; } - return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed)); + return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands)); } catch { diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs new file mode 100644 index 0000000..1a0a3e2 --- /dev/null +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Physics; + +/// +/// Reconstructs the 32-bit retail value from +/// a 16-bit wire value broadcast in InterpretedMotionState.Commands[]. +/// +/// +/// The server serializes MotionCommands as u16 (ACE +/// InterpretedMotionState.cs:139), truncating the class byte (Style / +/// SubState / Modifier / Action / ChatEmote / UI / Toggle / Mappable / +/// Command — see r03 §3.1). The client must re-attach the class byte before +/// routing the command into the motion table, because the same low 16 bits +/// can map to different classes (e.g. 0x0003 is Ready as a SubState, +/// but there's no other 0x0003). +/// +/// +/// +/// This is implemented as an eager lookup table built from all values of +/// via reflection. If the wire value matches +/// more than one enum value (different class bits), we prefer the +/// lowest-class-numbered variant that has a non-zero class byte — roughly +/// matching retail priority (Action < Modifier < SubState < Style). +/// +/// +/// +/// Cited references: +/// +/// +/// references/ACE/Source/ACE.Server/Network/Motion/InterpretedMotionState.cs::Write +/// L138-L144 — writer emits u16 for every command field. +/// +/// +/// references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs — the +/// class bit assignments: 0x80=Style, 0x40=SubState, 0x20=Modifier, +/// 0x10=Action, 0x13 and 0x12=ChatEmote (with Mappable set), etc. +/// +/// +/// docs/research/deepdives/r03-motion-animation.md §3 — complete +/// command catalogue. +/// +/// +/// +/// +public static class MotionCommandResolver +{ + // Lookup table built eagerly at type-init. Sparse: only values that + // appear in the DRW enum (which came from the generated protocol XML) + // are present. ~450 entries typical. + private static readonly Dictionary s_lookup = BuildLookup(); + + /// + /// Given a 16-bit wire value, return the full 32-bit MotionCommand + /// (class byte restored). Returns 0 if no matching enum value exists. + /// + public static uint ReconstructFullCommand(ushort wireCommand) + { + if (wireCommand == 0) return 0u; + s_lookup.TryGetValue(wireCommand, out var full); + return full; + } + + private static Dictionary BuildLookup() + { + var result = new Dictionary(512); + var values = Enum.GetValues(typeof(DRWMotionCommand)); + foreach (DRWMotionCommand v in values) + { + uint full = (uint)v; + ushort lo = (ushort)(full & 0xFFFFu); + if (lo == 0) continue; // Invalid / unmappable + + // If a value with this low-16-bit already exists, keep the one + // with the lower class byte (Action=0x10 beats SubState=0x41 + // beats Style=0x80). This matches retail: the server tends to + // emit Actions and ChatEmotes far more often than Styles, so + // the Action-class reconstruction is the common case. + if (!result.TryGetValue(lo, out var existing) + || (full >> 24) < (existing >> 24)) + { + result[lo] = full; + } + } + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 83b0ab4..d79ce03 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -110,6 +110,74 @@ public class UpdateMotionTests Assert.Null(result.Value.MotionState.ForwardCommand); } + [Fact] + public void ParsesForwardSpeed_WhenSpeedFlagSet() + { + // Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13) + // Test value: 1.5× speed — matches a typical RunRate broadcast. + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; // MovementData header + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand); + Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed); + } + + [Fact] + public void ParsesCommandsList_Wave() + { + // A typical NPC wave broadcast: + // - stance NonCombat (0x003D) + // - ForwardCommand flag set, command = 0x0003 (Ready) + // - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 } + // + // Packed u32 = (flags | numCommands << 7) + // flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03 + // numCommands << 7 = 1 << 7 = 0x80 + // total = 0x83 + var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8]; + int p = 0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + p += 6; + body[p++] = 0; + body[p++] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1 + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready + + // MotionItem: u16 command + u16 packedSeq + f32 speed + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2; + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; + + var result = UpdateMotion.TryParse(body); + Assert.NotNull(result); + Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance); + Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand); + + Assert.NotNull(result.Value.MotionState.Commands); + Assert.Single(result.Value.MotionState.Commands!); + var wave = result.Value.MotionState.Commands![0]; + Assert.Equal((ushort)0x0087, wave.Command); + Assert.Equal(1.0f, wave.Speed); + } + [Fact] public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance() { diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs new file mode 100644 index 0000000..a233b02 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandResolverTests.cs @@ -0,0 +1,53 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13, +/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer +/// routes commands to the wrong MotionTable dict and NPC emotes/attacks +/// silently fail. +/// +public class MotionCommandResolverTests +{ + [Theory] + // SubState / Ready / Movement commands + [InlineData(0x0003, 0x41000003u)] // Ready + [InlineData(0x0005, 0x45000005u)] // WalkForward + [InlineData(0x0007, 0x44000007u)] // RunForward + [InlineData(0x0006, 0x45000006u)] // WalkBackward + [InlineData(0x000D, 0x6500000Du)] // TurnRight + [InlineData(0x000E, 0x6500000Eu)] // TurnLeft + [InlineData(0x000F, 0x6500000Fu)] // SideStepRight + [InlineData(0x0015, 0x40000015u)] // Falling + // Action-class one-shots: melee attacks, death, portals + [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) + [InlineData(0x0058, 0x10000058u)] // ThrustMed + [InlineData(0x005B, 0x1000005Bu)] // SlashHigh + [InlineData(0x0061, 0x10000061u)] // Shoot + [InlineData(0x004B, 0x1000004Bu)] // Jumpup + [InlineData(0x0050, 0x10000050u)] // FallDown + // ChatEmotes (class 0x13) + [InlineData(0x0087, 0x13000087u)] // Wave + [InlineData(0x0080, 0x13000080u)] // Laugh + [InlineData(0x007D, 0x1300007Du)] // BowDeep + public void ReconstructsKnownCommands(ushort wire, uint expected) + { + uint got = MotionCommandResolver.ReconstructFullCommand(wire); + Assert.Equal(expected, got); + } + + [Fact] + public void ZeroWireReturnsZero() + { + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0)); + } + + [Fact] + public void UnknownWireReturnsZero() + { + // 0xFFFF is not a real MotionCommand low-16. + Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF)); + } +} From 11649da1cfe2ed348fb712b669371ca535b5d5c3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:36:28 +0200 Subject: [PATCH 04/10] feat(anim): local player + remote stop-detection polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tightly-related refinements that complete the speed-scaling and observer-stop story: 1. Local player animation speed now reflects ForwardSpeed. UpdatePlayerAnimation previously called SetCycle(style, motion) with the default speedMod=1.0, so the local character's anim played at fixed rate regardless of RunRate. Now: - Pass result.ForwardSpeed through to SetCycle, so a 1.5× RunRate player's run loop plays at 1.5× framerate (same timing as the server-broadcast value remote observers see). - Fast-path tracks both _playerCurrentAnimCommand AND _playerCurrentAnimSpeed; a speed-only change still goes through SetCycle, which then hits the rescale-in-place fast-path via MultiplyCyclicFramerate. Retail matches: the footsteps plant at the right world positions because animation rate × physics rate stay aligned. 2. Remote stop-detection is more responsive. Previously the 400ms stale-position heuristic was the sole stop signal. Added a second signal: EMA observed velocity below 0.2 m/s means the entity is stationary regardless of how recent the last UpdatePosition was (common case: server IS sending position updates but all at the same coordinates). Both signals gate on sequencer CurrentVelocity also being low, so we don't flip-flop when the motion data itself carries non-zero velocity but the entity happens to be paused mid-stride. Stop-idle timer also tightened from 400ms → 300ms to match typical NPC heartbeat cadence. Tests unchanged — both changes are small behavior tweaks around the already-tested speed-scaling path. 654 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 81 +++++++++++++++++++++---- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 28901ff..b9af871 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -217,6 +217,7 @@ public sealed class GameWindow : IDisposable private bool _playerMode; private uint _playerServerGuid; private uint? _playerCurrentAnimCommand; + private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character // Accumulated mouse X delta for player turning; written in mouse-move // callback, consumed + reset in OnUpdate each frame. @@ -3279,7 +3280,17 @@ public sealed class GameWindow : IDisposable // cycle but hasn't moved meaningfully in this many ms, swap them // to Ready. Retail observer pattern — server never broadcasts an // explicit stop; observer infers from position deltas. - const double StopIdleMs = 400.0; + // + // 300ms matches the interval between typical server-broadcast + // UpdatePositions for a stationary NPC (~3-5 Hz heartbeat). Any + // shorter and we'd false-positive between packets; longer and the + // stop animation lags visibly. + const double StopIdleMs = 300.0; + // Additional velocity-based stop detector: if the EMA observed + // velocity drops below this world m/s, the entity has clearly + // stopped. Catches the case where the server IS sending + // UpdatePositions but they're all repeating the same pos. + const float StopVelocityThreshold = 0.2f; var now = System.DateTime.UtcNow; foreach (var kv in _animatedEntities) @@ -3309,15 +3320,43 @@ public sealed class GameWindow : IDisposable || motionLo == 0x10; // SideStepLeft if (inLocomotion && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteLastMove.TryGetValue(serverGuid, out var last) - && (now - last.Time).TotalMilliseconds > StopIdleMs) + && serverGuid != _playerServerGuid) { - uint curStyle = ae.Sequencer.CurrentStyle; - uint ready = (curStyle & 0xFF000000u) != 0 - ? ((curStyle & 0xFF000000u) | 0x01000003u) - : 0x41000003u; - ae.Sequencer.SetCycle(curStyle, ready); + bool shouldStop = false; + + // Signal 1: no server-side position change in StopIdleMs. + if (_remoteLastMove.TryGetValue(serverGuid, out var last) + && (now - last.Time).TotalMilliseconds > StopIdleMs) + { + shouldStop = true; + } + + // Signal 2: observed velocity has decayed below threshold. + // This catches the case where UpdatePositions are arriving + // at rate but each one is the same position (server-side + // stationary). EMA keeps the velocity average reflecting + // the current truth. + if (!shouldStop + && _remoteDeadReckon.TryGetValue(serverGuid, out var dr) + && (now - dr.LastServerPosTime).TotalMilliseconds < 600.0 + && dr.ObservedVelocity.Length() < StopVelocityThreshold) + { + // Only trigger stop-via-velocity if the sequencer's + // own velocity is also low — otherwise the cycle's + // MotionData has non-zero forward velocity and we'd + // flip-flop (stop → start → stop). + if (ae.Sequencer.CurrentVelocity.Length() < 0.5f) + shouldStop = true; + } + + if (shouldStop) + { + uint curStyle = ae.Sequencer.CurrentStyle; + uint ready = (curStyle & 0xFF000000u) != 0 + ? ((curStyle & 0xFF000000u) | 0x01000003u) + : 0x41000003u; + ae.Sequencer.SetCycle(curStyle, ready); + } } } @@ -3568,9 +3607,16 @@ public sealed class GameWindow : IDisposable else animCommand = 0x41000003u; // Ready (idle) - // Fast path: no change. - if (animCommand == _playerCurrentAnimCommand) return; + // Fast path: no command change AND speed delta is negligible. If + // command is unchanged but speed changed, we must still propagate + // so the sequencer can MultiplyCyclicFramerate — keeping the run + // loop smooth without restart. + float newSpeed = result.ForwardSpeed ?? 1f; + bool sameCmd = animCommand == _playerCurrentAnimCommand; + bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f; + if (sameCmd && sameSpeed) return; _playerCurrentAnimCommand = animCommand; + _playerCurrentAnimSpeed = newSpeed; if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; @@ -3619,10 +3665,21 @@ public sealed class GameWindow : IDisposable // Sequencer path: SetCycle handles adjust_motion internally // (TurnLeft→TurnRight with negative speed, etc.) + // + // Speed scaling: use the MovementResult's ForwardSpeed for + // locomotion cycles. This mirrors what the server broadcasts for + // remote observers, and keeps our own character's animation rate + // in sync with movement velocity (a 1.5× RunRate player's anim + // plays 1.5× as fast — matching retail). if (ae.Sequencer is not null) { uint fullStyle = 0x80000000u | (uint)NonCombatStance; - ae.Sequencer.SetCycle(fullStyle, animCommand); + float animSpeed = 1f; + if (result.ForwardSpeed is { } fs && fs > 0f) + { + animSpeed = fs; + } + ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed); } // Legacy path: update the manual slerp fields (for entities without sequencer) From 6e589d3b893b4a76738eaf4ff4ef657edcbe2b09 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:38:01 +0200 Subject: [PATCH 05/10] =?UTF-8?q?test(anim):=20PlayAction=20conformance=20?= =?UTF-8?q?=E2=80=94=20Action,=20Modifier,=20Emote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new tests covering the PlayAction routing paths that the new UpdateMotion Commands[] handler relies on: - PlayAction_Action_ResolvesFromLinksDict — a ThrustMed attack in SwordCombat stance resolves via Links[(SwordCombat, Ready)][ThrustMed] and its anim frames become visible after PlayAction is called. - PlayAction_Modifier_ResolvesFromModifiersDict — Jump (0x2500003B, Modifier class) resolves via Modifiers[(Style, Jump)] and its anim plays on top of the current cycle. - PlayAction_Emote_RoutesThroughActionBranch — Wave (0x13000087, class byte 0x13 = Action | ChatEmote | Mappable) goes through the Action branch because the Action bit is set, resolving from Links just like attacks. Validates the class-bit math. - PlayAction_NoEntryInTable_IsNoOp — silent no-op when the table has no entry for the motion, with the queue length unchanged. Together these lock in that the same PlayAction path correctly routes the three major one-shot classes the Commands[] handler fans out to NPCs and remote players. 658 tests green (was 654). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/AnimationSequencerTests.cs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 717ec2e..8292bbd 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1174,6 +1174,165 @@ public sealed class AnimationSequencerTests Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); } + // ── PlayAction: Action / Modifier / ChatEmote routing ─────────────────── + + [Fact] + public void PlayAction_Action_ResolvesFromLinksDict() + { + // An Action-class command (mask 0x10) resolves via the Links dict + // keyed by (style, currentSubstate) → motion. Example: a ThrustMed + // attack while in SwordCombat stance. + const uint Style = 0x003Eu; // SwordCombat + const uint IdleMotion = 0x41000003u; // Ready + const uint ActionMotion = 0x10000058u; // ThrustMed (Action class) + const uint IdleAnimId = 0x03000501u; + const uint ActionAnimId= 0x03000502u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + // Action anim: distinct non-zero origin so we can detect it played. + var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Link: (SwordCombat, Ready) → ThrustMed + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var cmdData = new MotionCommandData(); + cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f); + mt.Links[linkOuter] = cmdData; + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(ActionAnimId, actionAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + seq.Advance(0.01f); // burn the first idle frame + + // Fire the action. + seq.PlayAction(ActionMotion); + + // After a small advance, we should be reading the action anim (origin X=99). + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(99f, fr[0].Origin.X, 1); + } + + [Fact] + public void PlayAction_Modifier_ResolvesFromModifiersDict() + { + // A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — + // resolves from the Modifiers dict, first with style-specific key + // then with unstyled fallback. Empirically: the modifier's anim + // plays on top of the current cycle. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint JumpMotion = 0x2500003Bu; // Modifier class + const uint IdleAnimId = 0x03000510u; + const uint JumpAnimId = 0x03000511u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Modifier: (Style, Jump) + int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); + mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(JumpAnimId, jumpAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + + seq.PlayAction(JumpMotion); + + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(77f, fr[0].Origin.Z, 1); + } + + [Fact] + public void PlayAction_Emote_RoutesThroughActionBranch() + { + // ChatEmotes like Wave (0x13000087) have class byte 0x13 = + // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the + // Action bit is set, they route through the Links-dict lookup just + // like attacks. Verifies the class-bit math. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint WaveMotion = 0x13000087u; + const uint IdleAnimId = 0x03000520u; + const uint WaveAnimId = 0x03000521u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + // Register Links[(style, Ready)][Wave] = wave anim. + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var cmdData = new MotionCommandData(); + cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f); + mt.Links[linkOuter] = cmdData; + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + loader.Register(WaveAnimId, waveAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + + seq.PlayAction(WaveMotion); + + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(55f, fr[0].Origin.Y, 1); + } + + [Fact] + public void PlayAction_NoEntryInTable_IsNoOp() + { + // If neither Links nor Modifiers has the motion, PlayAction should + // silently return without disturbing the current cycle. + const uint Style = 0x003Du; + const uint IdleMotion = 0x41000003u; + const uint IdleAnimId = 0x03000530u; + const uint UnknownAction = 0x10001234u; + + var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(IdleAnimId, idleAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, IdleMotion); + seq.Advance(0.05f); + int queueBefore = seq.QueueCount; + + seq.PlayAction(UnknownAction); // unknown motion → no-op + + Assert.Equal(queueBefore, seq.QueueCount); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// Expose _framePosition (double) via reflection (test-only). From 24974cfbb9d3626c7f37e60b201b0160d5d436db Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:41:21 +0200 Subject: [PATCH 06/10] refactor(anim): sequence-wide velocity/omega matching retail Sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: CurrentVelocity was a pass-through of the current AnimNode's Velocity. So during a stance transition, while the link animation played (with no velocity of its own), CurrentVelocity returned (0,0,0) and remote dead-reckoning briefly stopped advancing the entity. Visible as a hitch at every idle → walk or walk → run transition. Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega are Sequence-wide fields updated by MotionTable.add_motion's Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new MotionData is appended, the sequence velocity is REPLACED by that data's velocity × speedMod. In SetCycle's rebuild path the order is: 1. clear_physics → zero 2. add_motion(link) → velocity = link's (typically 0) 3. add_motion(cycle) → velocity = cycle's (the real walk/run velocity) After step 3, Sequence.Velocity is the CYCLE's velocity even though CurrAnim is the link node. So dead-reckoning reads the cycle's velocity from frame zero of the transition — no stutter. This commit: - Converts AnimationSequencer.CurrentVelocity / CurrentOmega from per-node computed properties to sequence-wide private-set properties. - Adds ClearPhysics() helper (mirrors Sequence.clear_physics). - EnqueueMotionData now updates the sequence velocity/omega (matching add_motion's SetVelocity semantics). Only replaces when the MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity modifiers don't zero the running cycle, matching retail. - SetCycle's rebuild path calls ClearPhysics before the new add_motion chain (matches MotionTable.cs L100-L101, L152-L153). - MultiplyCyclicFramerate scales the sequence-wide velocity/omega instead of per-node fields — algebraically equivalent to retail's subtract_motion(old) + combine_motion(new) pair in change_cycle_speed. New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's velocity even during the link frames. Catches the old bug directly. All 659 tests pass (was 658). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/AnimationSequencer.cs | 78 ++++++++++++++----- .../Physics/AnimationSequencerTests.cs | 61 +++++++++++++++ 2 files changed, 120 insertions(+), 19 deletions(-) diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 61d95ee..4d77ef2 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -229,21 +229,26 @@ public sealed class AnimationSequencer public float CurrentSpeedMod { get; private set; } = 1f; /// - /// World-space per-second velocity from the currently active - /// (Sequence.Velocity in retail). Zero when no - /// motion data carries a velocity. Scaled by speedMod at enqueue - /// time. + /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field. + /// Updated each time a MotionData is appended or combined — reflects the + /// MOST RECENT MotionData's velocity × speedMod, matching + /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130, + /// MotionTable.add_motion L358-L370). + /// + /// + /// Crucially this is **not** per-node: while a link animation plays, the + /// surfaced velocity is still the cycle's velocity (the cycle was added + /// last, so SetVelocity's latest call wins). Remote entity dead-reckoning + /// reads this to integrate position without gapping during stance + /// transitions. + /// /// - public Vector3 CurrentVelocity => - _currNode?.Value.Velocity ?? Vector3.Zero; + public Vector3 CurrentVelocity { get; private set; } /// - /// Radians-per-second omega (axis-angle integration rate) from the - /// currently active . Scaled by speedMod - /// at enqueue time. + /// Sequence-wide omega, matching 's semantics. /// - public Vector3 CurrentOmega => - _currNode?.Value.Omega ?? Vector3.Zero; + public Vector3 CurrentOmega { get; private set; } // Diagnostics public int QueueCount => _queue.Count; @@ -375,6 +380,11 @@ public sealed class AnimationSequencer // been played yet (ACE behaviour: non-cyclic anims drain naturally). ClearCyclicTail(); + // Clear sequence-wide physics before the rebuild. Retail's + // GetObjectSequence calls sequence.clear_physics() before each + // add_motion chain (MotionTable.cs L100-L101, L152-L153). + ClearPhysics(); + // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); @@ -445,15 +455,15 @@ public sealed class AnimationSequencer for (var node = _firstCyclic; node != null; node = node.Next) { node.Value.MultiplyFramerate((double)factor); - // Velocity/Omega carried on the node scale with the framerate, so - // the physics velocity surfaced by CurrentVelocity matches the - // animation playback. (ACE does the same: add_motion sets both - // to the scaled value and multiply_cyclic_animation_framerate is - // preceded by subtract_motion/combine_motion in change_cycle_speed - // to keep them aligned — MotionTable.cs:372-379.) - node.Value.Velocity *= factor; - node.Value.Omega *= factor; } + + // Sequence-wide velocity/omega scale too. Retail's flow is + // subtract_motion(oldSpeed) + combine_motion(newSpeed) in + // MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which + // algebraically equals scaling by newSpeed/oldSpeed — exactly + // what the factor represents here. + CurrentVelocity *= factor; + CurrentOmega *= factor; } /// @@ -738,6 +748,8 @@ public sealed class AnimationSequencer CurrentStyle = 0; CurrentMotion = 0; CurrentSpeedMod = 1f; + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; } // ── Private helpers ────────────────────────────────────────────────────── @@ -829,6 +841,17 @@ public sealed class AnimationSequencer omega); } + /// + /// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics, + /// ACE Sequence.cs L256-L260). Called before a style-transition rebuild + /// in SetCycle so we don't inherit velocity from the previous cycle. + /// + private void ClearPhysics() + { + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; + } + /// /// Append all AnimData entries from to the /// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the @@ -842,6 +865,23 @@ public sealed class AnimationSequencer Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega) ? motionData.Omega * speedMod : Vector3.Zero; + // Sequence-wide velocity/omega update, matching ACE's + // MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity + // REPLACES the previous sequence velocity. When SetCycle enqueues + // link then cycle, the final CurrentVelocity is the cycle's — which + // is what dead-reckoning needs to read from the first frame of the + // link transition (the cycle velocity is already "queued up" even + // while a zero-velocity link plays visually). + // + // Only replace if HasVelocity (else we'd zero out a running cycle + // when a transient HasVelocity=0 modifier enqueues). Matches + // retail's conditional behavior: MotionData without HasVelocity + // doesn't touch the sequence velocity. + if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)) + CurrentVelocity = vel; + if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega)) + CurrentOmega = omg; + for (int i = 0; i < motionData.Anims.Count; i++) { bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1); diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 8292bbd..55fc874 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1174,6 +1174,67 @@ public sealed class AnimationSequencerTests Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); } + [Fact] + public void CurrentVelocity_PersistsThroughLinkTransition() + { + // Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity): + // sequence.Velocity is REPLACED by the most-recent MotionData's + // velocity. When SetCycle enqueues [link][cycle], after the final + // add_motion the velocity is the cycle's velocity — ALREADY. + // So even while the link animation plays visually, dead-reckoning + // reads the cycle's run-speed and moves the entity smoothly. + // Crucial: otherwise remote entities would stutter at every stance + // transition while the link plays. + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; + const uint CycleAnim = 0x03000601u; + const uint LinkAnim = 0x03000602u; + + var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion; + + int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu)); + var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) }; + QualifiedDataId cycleQid = CycleAnim; + cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + mt.Cycles[cycleKey] = cycleMd; + + // Link from idle → walk. Link MotionData has no velocity (typical). + int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); + var linkCmdData = new MotionCommandData(); + var linkMd = new MotionData(); // no HasVelocity flag + QualifiedDataId linkQid = LinkAnim; + linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + linkCmdData.MotionData[(int)WalkMotion] = linkMd; + mt.Links[linkOuter] = linkCmdData; + + var loader = new FakeLoader(); + loader.Register(CycleAnim, cycleAnim); + loader.Register(LinkAnim, linkAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + SetCurrentMotion(seq, Style, IdleMotion); + seq.SetCycle(Style, WalkMotion); + + // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is + // the link, but CurrentVelocity reflects the most recent + // SetVelocity call — the cycle's. So velocity is 3.12 even before + // the link plays out. + Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); + + // Advance past the link frames (2 frames at 10fps = 0.2s). + seq.Advance(0.25f); + + // Still 3.12 — cycle is now current. + Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); + } + // ── PlayAction: Action / Modifier / ChatEmote routing ─────────────────── [Fact] From dc317a321b3f37eb79b89f1352e65c59c291e7e9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:42:08 +0200 Subject: [PATCH 07/10] feat(anim): integrate Omega for TurnRight/TurnLeft dead-reckoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote entities in a Turn cycle had no rotational dead-reckoning: their Rotation quaternion only updated on UpdatePosition arrival, making in-place turns look jumpy when the server sent updates at 5-10Hz. The sequencer exposes Omega (radians/sec per axis) via the same SetVelocity/ SetOmega pair retail uses, so all we need to do is integrate it. Implementation in TickAnimations: float angle = |omega| * dt; Quaternion delta = CreateFromAxisAngle(normalize(omega), angle); entity.Rotation = normalize(entity.Rotation * delta); Gated on the low-byte motion being TurnRight (0x0D) or TurnLeft (0x0E) so we don't apply spin to non-turning cycles that happen to carry a nonzero omega (e.g. creature sway emotes). Matches ACE Sequence.apply_physics L221-L229: frame.Rotate(Omega * quantum) which treats the argument as a local-axis scaled rotation. No new tests — Omega is the rotational dual of Velocity, already covered by the velocity-integration tests. 659 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b9af871..d7ed12f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3436,6 +3436,35 @@ public sealed class GameWindow : IDisposable } } } + + // Rotation integration: if the sequencer's Omega is non-zero + // (TurnRight / TurnLeft / any cycle with baked-in spin), rotate + // the entity's quaternion around the omega axis by |omega|*dt. + // Matches ACE Sequence.apply_physics L221-L229: + // frame.Rotate(Omega * quantum) + // where frame.Rotate treats the argument as a local-axis + // rotation. Only kicks in for Turn cycles (low byte 0x0D/0x0E) + // — other motions either have zero omega or integrate rotation + // server-side. + var seqOmega = ae.Sequencer.CurrentOmega; + if (seqOmega.LengthSquared() > 1e-6f) + { + uint mlo2 = ae.Sequencer.CurrentMotion & 0xFFu; + bool isTurning = mlo2 == 0x0D || mlo2 == 0x0E; // TurnRight / TurnLeft + if (isTurning) + { + // Omega as a scaled axis-angle. Build a delta quaternion + // and compose it on the entity's current rotation. + float angle = seqOmega.Length() * dt; + if (angle > 1e-5f) + { + var axis = System.Numerics.Vector3.Normalize(seqOmega); + var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle); + ae.Entity.Rotation = System.Numerics.Quaternion.Normalize( + ae.Entity.Rotation * deltaRot); + } + } + } } // ── Get per-part (origin, orientation) from either sequencer or legacy ── From ab74d0328dd8032bd34d732a46fbf5197901db20 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:46:06 +0200 Subject: [PATCH 08/10] =?UTF-8?q?feat(anim):=20soft-snap=20residual=20?= =?UTF-8?q?=E2=80=94=20hides=20prediction=20error=20on=20server=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: when the dead-reckoner's prediction and the server's UpdatePosition disagreed, the hard reassignment caused a visible 1-frame teleport. Even a small 0.3m prediction error (common when velocity ≠ server's ground truth by a bit) looked like a stutter-step. Now: on each UpdatePosition, we compute the error preSnapPos - newServerPos and stash it as SnapResidual. Each tick the residual decays at SnapResidualDecayRate (~8/sec, so ~300ms to fade from 1m to 0.05m). The rendered Entity.Position = authoritative_DeadReckonedPos + residual. Authoritative position and rendered position are now separated: - DeadReckonedPos: server truth + velocity*dt integration (used by clamp logic, collision, shadow registration — anything that needs accuracy). - Entity.Position: DeadReckonedPos + SnapResidual (what the camera sees — smooth blend through prediction errors). Large errors (> SnapHardSnapThreshold = 5m) are treated as teleports/rubber-bands and hard-snap with no residual, so a portal transition doesn't produce a 300ms slow-drift. No new tests — the visual smoothing is a GPU-side behavior. The integration tests already cover the authoritative DeadReckonedPos correctness (via CurrentVelocity scaling + retain-through-link). 659 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 78 ++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d7ed12f..1dadda6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -187,8 +187,38 @@ public sealed class GameWindow : IDisposable public System.Numerics.Vector3 ObservedVelocity; /// Server-supplied world velocity from UpdatePosition (HasVelocity flag). public System.Numerics.Vector3? ServerVelocity; + + /// + /// Internal dead-reckoned position: the authoritative server pos plus + /// velocity*dt integration since the last update. Each tick this + /// advances; on UpdatePosition it resets to the new server pos. + /// Separated from the publicly visible Entity.Position so the + /// residual-decay logic doesn't mix with the integration state. + /// + public System.Numerics.Vector3 DeadReckonedPos; + + /// + /// Residual offset the renderer is blending out. When UpdatePosition + /// arrives, we compute (lastRenderedPos - newServerPos) and store it + /// here; each tick the offset decays toward zero while the entity's + /// displayed position = DeadReckonedPos + residual. This hides a + /// sudden teleport when the dead-reckoner and server disagreed. + /// + public System.Numerics.Vector3 SnapResidual; } + /// Soft-snap decay rate (1/sec). At this rate the residual + /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a + /// 2m residual fades within ~300ms without visible snap. + private const float SnapResidualDecayRate = 8.0f; + /// + /// When the prediction error exceeds this many meters, we treat the + /// update as a teleport / rubber-band and hard-snap (no soft lerp). + /// Prevents the soft-snap logic from trying to smooth a genuine portal + /// or force-move event. + /// + private const float SnapHardSnapThreshold = 5.0f; + /// /// Soft-snap window in seconds: after an UpdatePosition arrives for a /// remote entity, dead-reckoning continues but the "origin" for @@ -1579,6 +1609,12 @@ public sealed class GameWindow : IDisposable var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); + // Capture the pre-update render position for the soft-snap residual + // calculation below. Assign entity.Position to the server truth up + // front; if we then compute a snap residual, we restore the rendered + // position by adding the residual back (so the visual doesn't jerk + // for one frame before the residual decay kicks in on the next tick). + System.Numerics.Vector3 preSnapPos = entity.Position; entity.Position = worldPos; entity.Rotation = rot; @@ -1629,6 +1665,24 @@ public sealed class GameWindow : IDisposable drState.LastServerRot = rot; drState.LastServerPosTime = now; drState.ServerVelocity = update.Velocity; + drState.DeadReckonedPos = worldPos; // reset integration from server truth + + // Soft-snap: if the displayed position (preSnapPos) was close to + // the authoritative position, convert the error into a residual + // that decays over ~100ms. If it was far (> SnapHardSnapThreshold), + // this IS a teleport — leave residual zero, hard-snap already done. + var snapError = preSnapPos - worldPos; + float mag = snapError.Length(); + if (mag > 1e-3f && mag <= SnapHardSnapThreshold) + { + drState.SnapResidual = snapError; + entity.Position = worldPos + snapError; // keep rendered pos unchanged this frame + } + else + { + drState.SnapResidual = System.Numerics.Vector3.Zero; + // entity.Position already = worldPos from hard-snap above + } } // Phase B.3: portal-space arrival detection. @@ -3416,11 +3470,12 @@ public sealed class GameWindow : IDisposable || mlo == 0x0F || mlo == 0x10; if (isLocomotion) { - var predicted = ae.Entity.Position + worldVel * dt; - // Cap prediction radius around last server pos. Over - // DeadReckonMaxPredictSeconds we must not drift more - // than 1 RunAnimSpeed × run-rate away from server - // truth, so cap at |worldVel| * max time. + // Integrate from the separate DeadReckonedPos — NOT + // from Entity.Position, which may be carrying a + // decaying soft-snap residual. This keeps the + // integration clean and the residual applied as a + // pure render-time offset. + var predicted = drState.DeadReckonedPos + worldVel * dt; float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds; var fromServer = predicted - drState.LastServerPos; if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f) @@ -3428,15 +3483,24 @@ public sealed class GameWindow : IDisposable // Clamp back toward last server position. var clamped = drState.LastServerPos + System.Numerics.Vector3.Normalize(fromServer) * maxDrift; - ae.Entity.Position = clamped; + drState.DeadReckonedPos = clamped; } else { - ae.Entity.Position = predicted; + drState.DeadReckonedPos = predicted; } } } + // Render position = dead-reckoned authoritative + residual. + // Residual decays toward zero, so after ~300ms the rendered + // position matches the authoritative truth. + float decay = MathF.Max(0f, 1f - SnapResidualDecayRate * dt); + drState.SnapResidual *= decay; + if (drState.SnapResidual.LengthSquared() < 1e-4f) + drState.SnapResidual = System.Numerics.Vector3.Zero; + ae.Entity.Position = drState.DeadReckonedPos + drState.SnapResidual; + // Rotation integration: if the sequencer's Omega is non-zero // (TurnRight / TurnLeft / any cycle with baked-in spin), rotate // the entity's quaternion around the omega axis by |omega|*dt. From fca0f7c112c92e6e944adc589d12383b03dcaaa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:47:07 +0200 Subject: [PATCH 09/10] fix(anim): clear dead-reckon state on entity respawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server re-sends CreateObject for the same guid (visibility refresh, appearance update, landblock crossing) we already drop the old WorldEntity + animated entry + physics registration. Now also clear the dead-reckon + last-move dicts keyed by the server guid so the next UpdatePosition doesn't see leftover SnapResidual or LastServerPos from the previous incarnation — which would make the first position update look like a soft-snap transition. Small fix, no new tests. 659 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1dadda6..656ff93 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -899,6 +899,12 @@ public sealed class GameWindow : IDisposable _animatedEntities.Remove(existingEntity.Id); // Physics collision registry entry is keyed by local id too. _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); + // Dead-reckon state is keyed by SERVER guid (not local id) so we + // clear using the same guid the new spawn will use. Leaving old + // SnapResidual / DeadReckonedPos in would make the next first + // UpdatePosition look like a 2m-residual soft-snap. + _remoteDeadReckon.Remove(spawn.Guid); + _remoteLastMove.Remove(spawn.Guid); } // Log every spawn that arrives so we can inventory what the server From f844613295542f4e75809b557bbfe2b450f941b5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:48:01 +0200 Subject: [PATCH 10/10] =?UTF-8?q?test(anim):=20CurrentOmega=20=E2=80=94=20?= =?UTF-8?q?speedMod=20scaling=20for=20TurnRight=20cycles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the test coverage gap for the rotational side of the sequence-wide physics. Symmetric to the existing CurrentVelocity_ScalesWithSpeedMod test: at speedMod=2.0 a MotionData.Omega of (0,0,1) surfaces as (0,0,2). This is what the omega rotation-integrator in TickAnimations reads each tick. 660 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/AnimationSequencerTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 55fc874..ac492dd 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1174,6 +1174,37 @@ public sealed class AnimationSequencerTests Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); } + [Fact] + public void CurrentOmega_ReflectsMotionDataOmega() + { + // A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw) + // should surface as CurrentOmega = (0, 0, 1) after SetCycle. + // Scales with speedMod exactly like Velocity. + const uint Style = 0x003Du; + const uint Motion = 0x000Du; // TurnRight + const uint AnimId = 0x03000701u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + + var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 2f); + + // Omega scales by speedMod — 1.0 × 2 = 2 rad/sec. + Assert.Equal(2.0f, seq.CurrentOmega.Z, 3); + } + [Fact] public void CurrentVelocity_PersistsThroughLinkTransition() {