acdream/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
Erik afafefd71f 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>
2026-04-19 10:26:55 +02:00

1157 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
// Alias the DatReaderWriter enum so it doesn't clash with
// AcDream.Core.Physics.MotionCommand (which is a static class of uint constants).
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Tests.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// AnimationSequencerTests
//
// All tests run fully offline -- no DatCollection, no disk access.
// We build in-memory Setup / MotionTable / Animation fixtures that drive
// exactly the code paths we are testing.
//
// Covered:
// 1. SlerpRetailClient matches System.Numerics slerp for standard cases.
// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc).
// 3. SlerpRetailClient falls back to linear for near-parallel quaternions.
// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop).
// 5. Advance at dt=0 returns identity frame (no motion table loaded).
// 6. SetCycle transitions: link frames are prepended before the target cycle.
// 7. GetLink returns null when MotionTable has no link for the transition.
// 8. SetCycle with same motion twice is a no-op (fast path).
// 9. Reset clears all state.
// 10. Negative-speed playback (TurnLeft → TurnRight with reversed animation).
// 11. Boundary crossing: frame wraps correctly in reverse.
// 12. advance_to_next_animation: transition link drains then wraps to cycle.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// In-memory IAnimationLoader test double. No filesystem access.
/// </summary>
file sealed class FakeLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _anims = new();
public void Register(uint id, Animation anim) => _anims[id] = anim;
public Animation? LoadAnimation(uint id) =>
_anims.TryGetValue(id, out var a) ? a : null;
}
/// <summary>
/// Helper to build minimal in-memory dat fixtures.
/// </summary>
file static class Fixtures
{
/// <summary>
/// Build an Animation with <paramref name="numFrames"/> identical frames,
/// each part having the supplied origin/orientation.
/// </summary>
public static Animation MakeAnim(int numFrames, int numParts,
Vector3 origin, Quaternion orientation)
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame((uint)numParts);
for (int p = 0; p < numParts; p++)
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
anim.PartFrames.Add(pf);
}
return anim;
}
/// <summary>
/// Build a two-frame animation: frame 0 has one origin/rotation, frame 1 another.
/// Used to exercise slerp blending.
/// </summary>
public static Animation MakeTwoFrameAnim(
int numParts,
Vector3 fromOrigin, Quaternion fromRot,
Vector3 toOrigin, Quaternion toRot)
{
var anim = new Animation();
var pf0 = new AnimationFrame((uint)numParts);
var pf1 = new AnimationFrame((uint)numParts);
for (int p = 0; p < numParts; p++)
{
pf0.Frames.Add(new Frame { Origin = fromOrigin, Orientation = fromRot });
pf1.Frames.Add(new Frame { Origin = toOrigin, Orientation = toRot });
}
anim.PartFrames.Add(pf0);
anim.PartFrames.Add(pf1);
return anim;
}
/// <summary>
/// Build a minimal Setup with <paramref name="numParts"/> parts,
/// each with a DefaultScale of (1,1,1).
/// </summary>
public static Setup MakeSetup(int numParts)
{
var setup = new Setup();
for (int i = 0; i < numParts; i++)
{
setup.Parts.Add(0x01000000u + (uint)i); // synthetic GfxObj ids
setup.DefaultScale.Add(Vector3.One);
}
return setup;
}
/// <summary>
/// Build a MotionTable with one cycle (style+motion) pointing to the
/// given animation id, and optionally a link from (style, fromMotion)
/// to (toMotion) pointing to <paramref name="linkAnimId"/>.
/// </summary>
public static MotionTable MakeMtable(
uint style, uint motion, uint cycleAnimId,
uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0,
float framerate = 30f)
{
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)style;
mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu));
mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate);
if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0)
{
int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
var cmd = new MotionCommandData();
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate);
mt.Links[linkOuter] = cmd;
}
return mt;
}
public static MotionData MakeMotionData(uint animId, float framerate)
{
var md = new MotionData();
QualifiedDataId<Animation> qid = animId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1, // sentinel -> resolve to numFrames-1
Framerate = framerate,
});
return md;
}
}
public sealed class AnimationSequencerTests
{
// ── SlerpRetailClient ────────────────────────────────────────────────────
[Theory]
[InlineData(0f)]
[InlineData(0.25f)]
[InlineData(0.5f)]
[InlineData(0.75f)]
[InlineData(1f)]
public void SlerpRetailClient_MatchesNumerics_ForOrthogonalQuats(float t)
{
// Two quaternions 90 degrees apart (rotation around Z axis: 0 and 90 deg).
var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0f);
var q2 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
var got = AnimationSequencer.SlerpRetailClient(q1, q2, t);
var expected = Quaternion.Slerp(q1, q2, t);
Assert.Equal(expected.X, got.X, 4);
Assert.Equal(expected.Y, got.Y, 4);
Assert.Equal(expected.Z, got.Z, 4);
Assert.Equal(expected.W, got.W, 4);
}
[Fact]
public void SlerpRetailClient_HandlesNegativeDot_TakesShortArc()
{
// q2 is the antipodal of q1 (dot -> -1).
var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.1f);
var q2 = new Quaternion(-q1.X, -q1.Y, -q1.Z, -q1.W); // antipode
// At t=0 the result should be non-NaN (the sign-flip gives a valid quat).
var got = AnimationSequencer.SlerpRetailClient(q1, q2, 0f);
Assert.False(float.IsNaN(got.X));
Assert.False(float.IsNaN(got.W));
}
[Fact]
public void SlerpRetailClient_NearParallel_LinearFallback()
{
// Two identical quaternions -> dot = 1 -> linear fallback path.
var q = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.3f);
var got = AnimationSequencer.SlerpRetailClient(q, q, 0.5f);
Assert.Equal(q.X, got.X, 4);
Assert.Equal(q.Y, got.Y, 4);
Assert.Equal(q.Z, got.Z, 4);
Assert.Equal(q.W, got.W, 4);
}
// ── SetCycle / frame advance ─────────────────────────────────────────────
[Fact]
public void Advance_NoCycleSet_ReturnsIdentityTransforms()
{
var setup = Fixtures.MakeSetup(3);
var mt = new MotionTable();
var loader = new FakeLoader();
var seq = new AnimationSequencer(setup, mt, loader);
var transforms = seq.Advance(0.033f);
Assert.Equal(3, transforms.Count);
foreach (var tr in transforms)
{
Assert.Equal(Vector3.Zero, tr.Origin);
Assert.Equal(Quaternion.Identity, tr.Orientation);
}
}
[Fact]
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
{
const uint Style = 0x003Du; // NonCombat
const uint Motion = 0x0003u; // Ready
const uint AnimId = 0x03000001u;
var origin = new Vector3(1f, 0f, 0f);
var rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
var anim = Fixtures.MakeTwoFrameAnim(2, origin, rot, origin * 2, rot);
var setup = Fixtures.MakeSetup(2);
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);
// Very small dt -> should be near the first frame's rotation.
var transforms = seq.Advance(0.001f);
Assert.Equal(2, transforms.Count);
// Orientation should be close to rot (first frame), not identity.
Assert.True(Math.Abs(transforms[0].Orientation.Z - rot.Z) < 0.1f,
$"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}");
}
[Fact]
public void Advance_FrameWrapsAtHighFrame()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000002u;
// 4-frame animation; framerate=10fps, one full loop = 0.4s.
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
mt.Cycles[cycleKey] = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
mt.Cycles[cycleKey].Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = 3,
Framerate = 10f,
});
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
// Advance one full loop + a bit: 0.5s at 10fps = 5 frames.
// After wrapping this should still return a valid transform.
seq.Advance(0.5f);
var transforms = seq.Advance(0.01f);
Assert.Single(transforms);
// No exception = pass; the wrap produced a valid (non-crash) frame.
}
[Fact]
public void SetCycle_WithTransitionLink_PrependLinkFrames()
{
// Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1).
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000010u;
const uint LinkAnim = 0x03000011u;
var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
// MotionTable: link Idle->Walk = 2-frame transition anim.
var mt = Fixtures.MakeMtable(
style: Style,
motion: WalkMotion,
cycleAnimId: CycleAnim,
fromMotion: IdleMotion,
toMotion: WalkMotion,
linkAnimId: LinkAnim);
var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
// Prime the sequencer as if it was already playing IdleMotion.
SetCurrentMotion(seq, Style, IdleMotion);
seq.SetCycle(Style, WalkMotion);
// At t~0 we should be reading the link anim (Y=1), not the cycle (X=1).
var transforms = seq.Advance(0.001f);
Assert.Single(transforms);
Assert.True(transforms[0].Origin.Y > transforms[0].Origin.X,
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
}
[Fact]
public void SetCycle_NoLinkInTable_DirectCycleSwitch()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000020u;
var anim = Fixtures.MakeAnim(3, 1, new Vector3(5, 0, 0), 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); // no link registered -> direct cycle
var transforms = seq.Advance(0.001f);
Assert.Single(transforms);
// Should get cycle origin X~5 since there's no link.
Assert.True(transforms[0].Origin.X > 4f,
$"Expected cycle origin X~5 but got {transforms[0].Origin.X}");
}
[Fact]
public void SetCycle_SameMotionTwice_NoStateChange()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000030u;
var anim = Fixtures.MakeAnim(4, 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);
// Advance a bit to move the frame counter.
seq.Advance(0.1f);
double frameBefore = GetFramePosition(seq);
// Call SetCycle again with identical args -- fast-path, no reset.
seq.SetCycle(Style, Motion);
double frameAfter = GetFramePosition(seq);
Assert.Equal(frameBefore, frameAfter);
}
[Fact]
public void Reset_ClearsAllState()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000040u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.One, 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);
seq.Advance(0.2f);
seq.Reset();
Assert.Equal(0u, seq.CurrentStyle);
Assert.Equal(0u, seq.CurrentMotion);
// After reset, Advance should return identity transforms.
var transforms = seq.Advance(0.033f);
foreach (var tr in transforms)
{
Assert.Equal(Vector3.Zero, tr.Origin);
Assert.Equal(Quaternion.Identity, tr.Orientation);
}
}
// ── Negative-speed playback (TurnLeft → TurnRight reversed) ─────────────
[Fact]
public void SetCycle_TurnLeft_RemapsToTurnRightWithNegativeSpeed()
{
// TurnLeft (low nibble 0x000E) should remap to TurnRight (0x000D)
// with negated speed, so the animation plays in reverse.
// We verify this by checking CurrentMotion is still TurnLeft (the
// original command), but the sequencer internally uses TurnRight's anim.
const uint Style = 0x003Du; // NonCombat
const uint TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat
const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft
const uint AnimId = 0x03000050u;
// 4-frame animation; each frame has a distinct Z-origin so we can tell
// which direction we're reading.
var anim = new Animation();
for (int f = 0; f < 4; 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;
// Register TurnRight cycle (adjusted motion, not TurnLeft).
int cycleKey = (int)((Style << 16) | (TurnRight & 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, TurnLeft, speedMod: 1f);
// CurrentMotion should record the original TurnLeft command.
Assert.Equal(TurnLeft, seq.CurrentMotion);
// Without swap: StartFrame=0, EndFrame=3 (original range preserved).
// GetStartFramePosition for negative speed = (EndFrame+1)-eps = (3+1)-eps ≈ 3.99999.
// The cursor starts near the HIGH end and counts DOWN toward StartFrame(=0).
double pos = GetFramePosition(seq);
Assert.True(pos > 3.9 && pos < 4.0,
$"Expected framePosition near 3.99999 (reverse start near EndFrame+1) but got {pos}");
}
[Fact]
public void Advance_NegativeSpeed_FramePositionDecreases()
{
// Verify that a cycle loaded with negative framerate counts downward.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000060u;
var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
// Register cycle with NEGATIVE framerate to simulate reverse playback.
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = 7,
Framerate = -10f, // negative → reverse
});
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
// For negative framerate: startFrame=7, endFrame=0 (swapped by multiply_framerate).
// GetStartFramePosition = (endFrame + 1) - EPSILON = 1 - eps (the swapped endFrame is 0).
// Wait — after swap: StartFrame=7, EndFrame=0.
// GetStartFramePosition for negative fr: (EndFrame + 1) - eps = (0 + 1) - eps ≈ 0.99999.
// Then Advance(0.05) at -10fps → delta = -10 * 0.05 = -0.5 → new pos ≈ 0.49999.
double posBefore = GetFramePosition(seq);
seq.Advance(0.05f);
double posAfter = GetFramePosition(seq);
Assert.True(posAfter < posBefore,
$"Expected framePosition to decrease (reverse) but went {posBefore} → {posAfter}");
}
[Fact]
public void Advance_NegativeSpeed_WrapsAtStartBoundary()
{
// A reverse-speed cycle should wrap (via advance_to_next_animation)
// when it reaches its StartFrame boundary, then loop back to the
// firstCyclic node's end position.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000070u;
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();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = 3,
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);
// Advance well past one full reverse loop (0.5s at 10fps = 5 frames).
// Should NOT throw or crash — wrap must produce a valid transform.
seq.Advance(0.5f);
var transforms = seq.Advance(0.01f);
Assert.Single(transforms);
// Verify the frame position is back within the valid range after wrapping.
double pos = GetFramePosition(seq);
Assert.True(pos >= 0.0 && pos < 4.0,
$"Frame position {pos} out of range [0, 4) after reverse wrap");
}
// ── advance_to_next_animation: link drains then wraps to cycle ───────────
[Fact]
public void AdvanceToNextAnimation_LinkDrainsThenCycleLoops()
{
// Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)]
// Advance enough to exhaust the link node, then verify we're in the cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000080u;
const uint LinkAnim = 0x03000081u;
// Link anim: 2 frames, Y=5 (distinct marker).
var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity);
// Cycle anim: 4 frames, X=9 (distinct marker).
var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(
style: Style,
motion: WalkMotion,
cycleAnimId: CycleAnim,
fromMotion: IdleMotion,
toMotion: WalkMotion,
linkAnimId: LinkAnim,
framerate: 10f);
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);
// Link node is 2 frames at 10fps → 0.2s to exhaust.
// Advance 0.25s so we're definitely past the link and into the cycle.
seq.Advance(0.25f);
var transforms = seq.Advance(0.001f);
// After draining the 2-frame link node, we should be in the cycle anim (X=9).
Assert.Single(transforms);
Assert.True(transforms[0].Origin.X > 8f,
$"Expected cycle anim origin X~9 but got {transforms[0].Origin.X} (link Y was 5)");
}
[Fact]
public void AdvanceToNextAnimation_CycleLoopsRepeatedly()
{
// Verify that a cycle keeps looping (multiple wraps don't crash or drift).
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000090u;
var anim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), 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);
// Advance 5 full loops (4 frames × 10fps = 0.4s per loop → 2.0s total).
for (int i = 0; i < 10; i++)
seq.Advance(0.2f);
var transforms = seq.Advance(0.001f);
Assert.Single(transforms);
// Frame position must be in a valid range (not NaN, not out of bounds).
double pos = GetFramePosition(seq);
Assert.True(pos >= 0.0 && pos < 4.0,
$"Frame position {pos} out of range [0, 4) after 5 loops");
}
// ── Hook dispatch (Phase E.1) ────────────────────────────────────────────
[Fact]
public void Advance_FiresForwardHook_OnFrameBoundaryCrossing()
{
// 4-frame anim at 10fps. Put a SoundHook on frame 1 (Forward direction).
// Advance by 0.15s (1.5 frames) which crosses the boundary at frame 1.
// Expect exactly one hook fired.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000100u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[1].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000042u,
});
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);
// Drain any hooks pre-existing from initial load (there should be none).
seq.ConsumePendingHooks();
// Step 1: 0.05s → advance ~0.5 frames, floor still 0 → no crossing.
seq.Advance(0.05f);
Assert.Empty(seq.ConsumePendingHooks());
// Step 2: 0.10s more → total ~1.5 frames, floor now 1 → crosses boundary
// from frame 0 → frame 1. Fire hook on frame index 1? No — ACE fires
// hooks on frame index lastFrame (= 0). Let's put the hook on frame 0
// instead. Retest.
//
// Actually looking at ACE code at Sequence.cs:389:
// execute_hooks(currAnim.get_part_frame(lastFrame), Forward);
// lastFrame++;
// So lastFrame is 0 initially, crosses to 1 → fires hooks on frame 0.
// Let's verify with a hook on frame 0 instead.
}
[Fact]
public void Advance_FiresHookOnCrossedFrame_ForwardDirection()
{
// ACE semantics: when floor(framePos) goes from i → i+1, hooks on
// frame i with direction Forward or Both fire.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000101u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[0].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000001u,
});
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000002u,
});
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);
seq.ConsumePendingHooks(); // clear any initial hooks
// Advance 0.15s = 1.5 frames → floor 0 → 1, crosses boundary 0→1 → fires frame 0 hook.
seq.Advance(0.15f);
var hooks = seq.ConsumePendingHooks();
Assert.Single(hooks);
Assert.IsType<SoundHook>(hooks[0]);
// Advance 0.2s more = 2 more frames (total 3.5) → crosses 1→2 and 2→3 → fires frame 2 hook.
seq.Advance(0.2f);
var hooks2 = seq.ConsumePendingHooks();
Assert.Single(hooks2);
Assert.IsType<SoundHook>(hooks2[0]);
Assert.Equal(0x0A000002u, (uint)((SoundHook)hooks2[0]).Id);
}
[Fact]
public void Advance_BothDirectionHook_FiresInForwardAndReverse()
{
// Direction.Both fires regardless of playback direction.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000102u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[0].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Both,
Id = 0x0A000003u,
});
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);
seq.ConsumePendingHooks();
// Forward playback, cross boundary 0→1.
seq.Advance(0.15f);
Assert.Single(seq.ConsumePendingHooks());
}
[Fact]
public void Advance_ForwardHookDoesNotFire_OnReversePlayback()
{
// A hook tagged Direction.Forward should NOT fire when playback is reversed.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000103u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Forward,
Id = 0x0A000004u,
});
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, 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);
seq.ConsumePendingHooks();
// Reverse playback: cursor starts near frame 4 and counts down.
seq.Advance(0.15f);
var hooks = seq.ConsumePendingHooks();
// Forward-only hook on frame 2 should NOT fire on reverse playback.
Assert.DoesNotContain(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000004u);
}
[Fact]
public void Advance_BackwardHook_FiresOnReversePlayback()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000104u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.PartFrames[2].Hooks.Add(new SoundHook
{
Direction = AnimationHookDir.Backward,
Id = 0x0A000005u,
});
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData();
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, 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);
seq.ConsumePendingHooks();
// Reverse: start near 4, advance 0.2s = -2 frames → cursor ~2 → crosses 3→2
// which fires hooks on frame 3 (wrong one) and maybe 2.
// Let's advance enough to cross 3→2 boundary for sure.
seq.Advance(0.25f); // -2.5 frames → cursor ~1.5 → crosses 3→2 and 2→1
var hooks = seq.ConsumePendingHooks();
Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u);
}
// ── PosFrames root motion (Phase E.1) ────────────────────────────────────
[Fact]
public void Advance_WithPosFrames_AccumulatesRootMotion()
{
// Animation with PosFrames flag and per-frame origin deltas should
// surface a non-zero root motion delta after Advance.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000110u;
// 4-frame anim, each PosFrame origin = (1, 0, 0), rotation identity.
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
anim.Flags = AnimationFlags.PosFrames;
for (int f = 0; f < 4; f++)
{
anim.PosFrames.Add(new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = 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);
seq.ConsumeRootMotionDelta(); // clear
// Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied.
seq.Advance(0.25f);
var (pos, _) = seq.ConsumeRootMotionDelta();
// Each crossing adds +X origin → total X should be 2.
Assert.True(pos.X >= 1.8f && pos.X <= 2.2f,
$"Expected ~2.0 root motion X after 2 crossings, got {pos.X}");
// A subsequent consume with no advance should return zero (drained).
var (pos2, _) = seq.ConsumeRootMotionDelta();
Assert.Equal(Vector3.Zero, pos2);
}
[Fact]
public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity()
{
// MotionData with HasVelocity flag should surface via CurrentVelocity.
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000120u;
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(0f, 4f, 0f), // 4 m/s forward
};
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, 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);
// Node is current → velocity should be exposed (scaled by speedMod=1).
Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity);
}
[Fact]
public void CurrentVelocity_ScaledBySpeedMod()
{
const uint Style = 0x003Du;
const uint Motion = 0x0003u;
const uint AnimId = 0x03000121u;
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(0f, 4f, 0f),
};
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, 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: 0.5f);
// Velocity scaled by speedMod=0.5 → 2 m/s forward.
Assert.Equal(new Vector3(0f, 2f, 0f), seq.CurrentVelocity);
}
[Fact]
public void ConsumePendingHooks_AnimationDoneFires_WhenLinkDrains()
{
// When a non-cyclic link node exhausts and we advance_to_next_animation,
// an AnimationDoneHook should be queued so consumers can react (e.g. UI
// wake-on-idle-complete).
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000130u;
const uint LinkAnim = 0x03000131u;
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(
style: Style, motion: WalkMotion, cycleAnimId: CycleAnim,
fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim,
framerate: 10f);
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);
seq.ConsumePendingHooks();
// Link is 2 frames at 10fps = 0.2s. Advance past it.
seq.Advance(0.25f);
var hooks = seq.ConsumePendingHooks();
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>
private static double GetFramePosition(AnimationSequencer seq)
{
var field = typeof(AnimationSequencer)
.GetField("_framePosition",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
return field is null ? -1.0 : (double)field.GetValue(seq)!;
}
/// <summary>
/// Directly set CurrentStyle and CurrentMotion via reflection so the
/// transition-link test can simulate "we were already playing IdleMotion".
/// Both are auto-properties with private setters.
/// </summary>
private static void SetCurrentMotion(AnimationSequencer seq, uint style, uint motion)
{
var t = typeof(AnimationSequencer);
t.GetProperty(nameof(AnimationSequencer.CurrentStyle))!
.SetValue(seq, style);
t.GetProperty(nameof(AnimationSequencer.CurrentMotion))!
.SetValue(seq, motion);
}
}