Before: CurrentVelocity was a pass-through of the current AnimNode's Velocity. So during a stance transition, while the link animation played (with no velocity of its own), CurrentVelocity returned (0,0,0) and remote dead-reckoning briefly stopped advancing the entity. Visible as a hitch at every idle → walk or walk → run transition. Retail's model (ACE Sequence.cs L16-L17, L127-L130): Velocity and Omega are Sequence-wide fields updated by MotionTable.add_motion's Sequence.SetVelocity call (MotionTable.cs L358-L370). Every time a new MotionData is appended, the sequence velocity is REPLACED by that data's velocity × speedMod. In SetCycle's rebuild path the order is: 1. clear_physics → zero 2. add_motion(link) → velocity = link's (typically 0) 3. add_motion(cycle) → velocity = cycle's (the real walk/run velocity) After step 3, Sequence.Velocity is the CYCLE's velocity even though CurrAnim is the link node. So dead-reckoning reads the cycle's velocity from frame zero of the transition — no stutter. This commit: - Converts AnimationSequencer.CurrentVelocity / CurrentOmega from per-node computed properties to sequence-wide private-set properties. - Adds ClearPhysics() helper (mirrors Sequence.clear_physics). - EnqueueMotionData now updates the sequence velocity/omega (matching add_motion's SetVelocity semantics). Only replaces when the MotionData's HasVelocity/HasOmega flags are set — zero-HasVelocity modifiers don't zero the running cycle, matching retail. - SetCycle's rebuild path calls ClearPhysics before the new add_motion chain (matches MotionTable.cs L100-L101, L152-L153). - MultiplyCyclicFramerate scales the sequence-wide velocity/omega instead of per-node fields — algebraically equivalent to retail's subtract_motion(old) + combine_motion(new) pair in change_cycle_speed. New test: CurrentVelocity_PersistsThroughLinkTransition — verifies that after SetCycle enqueues [link][cycle], CurrentVelocity is the cycle's velocity even during the link frames. Catches the old bug directly. All 659 tests pass (was 658). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1422 lines
55 KiB
C#
1422 lines
55 KiB
C#
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 CurrentVelocity_ScalesWithSpeedMod()
|
||
{
|
||
// A RunForward motion with MotionData.Velocity = (0,4,0) should
|
||
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
|
||
// 0.5×. The dead-reckoning integrator in TickAnimations reads
|
||
// CurrentVelocity each tick, so this has to be accurate.
|
||
const uint Style = 0x003Du;
|
||
const uint Motion = 0x0007u;
|
||
const uint AnimId = 0x03000405u;
|
||
|
||
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(0, 4, 0) };
|
||
QualifiedDataId<Animation> qid = AnimId;
|
||
md.Anims.Add(new AnimData
|
||
{
|
||
AnimId = qid,
|
||
LowFrame = 0,
|
||
HighFrame = -1,
|
||
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);
|
||
Assert.Equal(4f, seq.CurrentVelocity.Y, 3);
|
||
|
||
// Start a fresh sequencer so the initial SetCycle applies speedMod.
|
||
var seq2 = new AnimationSequencer(setup, mt, loader);
|
||
seq2.SetCycle(Style, Motion, speedMod: 1.5f);
|
||
Assert.Equal(6f, seq2.CurrentVelocity.Y, 3);
|
||
|
||
// Same-motion rescale path also updates velocity.
|
||
seq2.SetCycle(Style, Motion, speedMod: 0.5f);
|
||
Assert.Equal(2f, seq2.CurrentVelocity.Y, 2);
|
||
}
|
||
|
||
[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);
|
||
}
|
||
|
||
[Fact]
|
||
public void CurrentVelocity_PersistsThroughLinkTransition()
|
||
{
|
||
// Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity):
|
||
// sequence.Velocity is REPLACED by the most-recent MotionData's
|
||
// velocity. When SetCycle enqueues [link][cycle], after the final
|
||
// add_motion the velocity is the cycle's velocity — ALREADY.
|
||
// So even while the link animation plays visually, dead-reckoning
|
||
// reads the cycle's run-speed and moves the entity smoothly.
|
||
// Crucial: otherwise remote entities would stutter at every stance
|
||
// transition while the link plays.
|
||
const uint Style = 0x003Du;
|
||
const uint IdleMotion = 0x0003u;
|
||
const uint WalkMotion = 0x0005u;
|
||
const uint CycleAnim = 0x03000601u;
|
||
const uint LinkAnim = 0x03000602u;
|
||
|
||
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
|
||
|
||
var setup = Fixtures.MakeSetup(1);
|
||
var mt = new MotionTable();
|
||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion;
|
||
|
||
int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
|
||
var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
|
||
QualifiedDataId<Animation> cycleQid = CycleAnim;
|
||
cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
|
||
mt.Cycles[cycleKey] = cycleMd;
|
||
|
||
// Link from idle → walk. Link MotionData has no velocity (typical).
|
||
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
var linkCmdData = new MotionCommandData();
|
||
var linkMd = new MotionData(); // no HasVelocity flag
|
||
QualifiedDataId<Animation> linkQid = LinkAnim;
|
||
linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
|
||
linkCmdData.MotionData[(int)WalkMotion] = linkMd;
|
||
mt.Links[linkOuter] = linkCmdData;
|
||
|
||
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);
|
||
|
||
// We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
|
||
// the link, but CurrentVelocity reflects the most recent
|
||
// SetVelocity call — the cycle's. So velocity is 3.12 even before
|
||
// the link plays out.
|
||
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
|
||
|
||
// Advance past the link frames (2 frames at 10fps = 0.2s).
|
||
seq.Advance(0.25f);
|
||
|
||
// Still 3.12 — cycle is now current.
|
||
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
|
||
}
|
||
|
||
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
|
||
|
||
[Fact]
|
||
public void PlayAction_Action_ResolvesFromLinksDict()
|
||
{
|
||
// An Action-class command (mask 0x10) resolves via the Links dict
|
||
// keyed by (style, currentSubstate) → motion. Example: a ThrustMed
|
||
// attack while in SwordCombat stance.
|
||
const uint Style = 0x003Eu; // SwordCombat
|
||
const uint IdleMotion = 0x41000003u; // Ready
|
||
const uint ActionMotion = 0x10000058u; // ThrustMed (Action class)
|
||
const uint IdleAnimId = 0x03000501u;
|
||
const uint ActionAnimId= 0x03000502u;
|
||
|
||
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||
// Action anim: distinct non-zero origin so we can detect it played.
|
||
var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity);
|
||
|
||
var setup = Fixtures.MakeSetup(1);
|
||
var mt = new MotionTable();
|
||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||
|
||
// Link: (SwordCombat, Ready) → ThrustMed
|
||
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
var cmdData = new MotionCommandData();
|
||
cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f);
|
||
mt.Links[linkOuter] = cmdData;
|
||
|
||
var loader = new FakeLoader();
|
||
loader.Register(IdleAnimId, idleAnim);
|
||
loader.Register(ActionAnimId, actionAnim);
|
||
|
||
var seq = new AnimationSequencer(setup, mt, loader);
|
||
seq.SetCycle(Style, IdleMotion);
|
||
seq.Advance(0.01f); // burn the first idle frame
|
||
|
||
// Fire the action.
|
||
seq.PlayAction(ActionMotion);
|
||
|
||
// After a small advance, we should be reading the action anim (origin X=99).
|
||
var fr = seq.Advance(0.01f);
|
||
Assert.Single(fr);
|
||
Assert.Equal(99f, fr[0].Origin.X, 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void PlayAction_Modifier_ResolvesFromModifiersDict()
|
||
{
|
||
// A Modifier-class command (mask 0x20) — like Jump (0x2500003B) —
|
||
// resolves from the Modifiers dict, first with style-specific key
|
||
// then with unstyled fallback. Empirically: the modifier's anim
|
||
// plays on top of the current cycle.
|
||
const uint Style = 0x003Du;
|
||
const uint IdleMotion = 0x41000003u;
|
||
const uint JumpMotion = 0x2500003Bu; // Modifier class
|
||
const uint IdleAnimId = 0x03000510u;
|
||
const uint JumpAnimId = 0x03000511u;
|
||
|
||
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||
var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity);
|
||
|
||
var setup = Fixtures.MakeSetup(1);
|
||
var mt = new MotionTable();
|
||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||
|
||
// Modifier: (Style, Jump)
|
||
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
|
||
mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
|
||
|
||
var loader = new FakeLoader();
|
||
loader.Register(IdleAnimId, idleAnim);
|
||
loader.Register(JumpAnimId, jumpAnim);
|
||
|
||
var seq = new AnimationSequencer(setup, mt, loader);
|
||
seq.SetCycle(Style, IdleMotion);
|
||
|
||
seq.PlayAction(JumpMotion);
|
||
|
||
var fr = seq.Advance(0.01f);
|
||
Assert.Single(fr);
|
||
Assert.Equal(77f, fr[0].Origin.Z, 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void PlayAction_Emote_RoutesThroughActionBranch()
|
||
{
|
||
// ChatEmotes like Wave (0x13000087) have class byte 0x13 =
|
||
// Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the
|
||
// Action bit is set, they route through the Links-dict lookup just
|
||
// like attacks. Verifies the class-bit math.
|
||
const uint Style = 0x003Du;
|
||
const uint IdleMotion = 0x41000003u;
|
||
const uint WaveMotion = 0x13000087u;
|
||
const uint IdleAnimId = 0x03000520u;
|
||
const uint WaveAnimId = 0x03000521u;
|
||
|
||
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||
var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity);
|
||
|
||
var setup = Fixtures.MakeSetup(1);
|
||
var mt = new MotionTable();
|
||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||
|
||
// Register Links[(style, Ready)][Wave] = wave anim.
|
||
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
|
||
var cmdData = new MotionCommandData();
|
||
cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f);
|
||
mt.Links[linkOuter] = cmdData;
|
||
|
||
var loader = new FakeLoader();
|
||
loader.Register(IdleAnimId, idleAnim);
|
||
loader.Register(WaveAnimId, waveAnim);
|
||
|
||
var seq = new AnimationSequencer(setup, mt, loader);
|
||
seq.SetCycle(Style, IdleMotion);
|
||
|
||
seq.PlayAction(WaveMotion);
|
||
|
||
var fr = seq.Advance(0.01f);
|
||
Assert.Single(fr);
|
||
Assert.Equal(55f, fr[0].Origin.Y, 1);
|
||
}
|
||
|
||
[Fact]
|
||
public void PlayAction_NoEntryInTable_IsNoOp()
|
||
{
|
||
// If neither Links nor Modifiers has the motion, PlayAction should
|
||
// silently return without disturbing the current cycle.
|
||
const uint Style = 0x003Du;
|
||
const uint IdleMotion = 0x41000003u;
|
||
const uint IdleAnimId = 0x03000530u;
|
||
const uint UnknownAction = 0x10001234u;
|
||
|
||
var idleAnim = 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) | (IdleMotion & 0xFFFFFFu));
|
||
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
|
||
|
||
var loader = new FakeLoader();
|
||
loader.Register(IdleAnimId, idleAnim);
|
||
|
||
var seq = new AnimationSequencer(setup, mt, loader);
|
||
seq.SetCycle(Style, IdleMotion);
|
||
seq.Advance(0.05f);
|
||
int queueBefore = seq.QueueCount;
|
||
|
||
seq.PlayAction(UnknownAction); // unknown motion → no-op
|
||
|
||
Assert.Equal(queueBefore, seq.QueueCount);
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
}
|