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).