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.
// ─────────────────────────────────────────────────────────────────────────────
///
/// In-memory IAnimationLoader test double. No filesystem access.
///
file sealed class FakeLoader : IAnimationLoader
{
private readonly Dictionary _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;
}
///
/// Helper to build minimal in-memory dat fixtures.
///
file static class Fixtures
{
///
/// Build an Animation with identical frames,
/// each part having the supplied origin/orientation.
///
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;
}
///
/// Build a two-frame animation: frame 0 has one origin/rotation, frame 1 another.
/// Used to exercise slerp blending.
///
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;
}
///
/// Build a minimal Setup with parts,
/// each with a DefaultScale of (1,1,1).
///
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;
}
///
/// 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 .
///
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 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 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);
// After FUN_005267E0 (multiply_framerate) swaps low↔high for negative speed:
// StartFrame = 3 (was high), EndFrame = 0 (was low)
// GetStartFramePosition for negative speed = (EndFrame + 1) - EPSILON = (0+1) - eps ≈ 0.99999.
// The cursor starts just below frame 1 and counts DOWN toward EndFrame(=0).
double pos = GetFramePosition(seq);
Assert.True(pos > 0.9 && pos < 1.0,
$"Expected framePosition near 0.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 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 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");
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Expose _framePosition (double) via reflection (test-only).
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)!;
}
///
/// 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.
///
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);
}
}