feat(anim): MultiplyCyclicFramerate — retail mid-cycle speed change

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:26:55 +02:00
parent 63b6922fc2
commit afafefd71f
3 changed files with 250 additions and 3 deletions

View file

@ -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<Animation> 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 ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>