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