From afafefd71fb34599dbe6b05d97d3af7465e13c30 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:26:55 +0200 Subject: [PATCH] =?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).