acdream/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
Erik d82f07d4e5 feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired
The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).

AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).

GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).

5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.

Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.

Registers: AP-73 deleted; AP-76 added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:55:13 +02:00

1844 lines
79 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_MissingCycle_LeavesSequenceAndStateUntouched()
{
// R2-Q5: HasCycle + the caller-side fallback chains are DELETED.
// The retail mechanism replacing the L.1c "torso on the ground"
// guard: GetObjectSequence (0x00522860) checks the cycle BEFORE any
// list surgery — a missing cycle leaves the sequence AND MotionState
// untouched, so whatever was playing (here the initialize_state
// default) keeps playing.
const uint Style = 0x8000003Cu; // HandCombat (full command)
const uint ReadyMotion = 0x41000003u;
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
seq.InitializeState();
Assert.Equal(ReadyMotion, seq.CurrentMotion);
int nodesBefore = seq.QueueCount;
// RunForward (0x44000007) is NOT in the table — the dispatch fails
// and nothing changes (no cyclic-tail wipe, no state overwrite).
seq.SetCycle(Style, 0x44000007u);
Assert.Equal(ReadyMotion, seq.CurrentMotion);
Assert.Equal(nodesBefore, seq.QueueCount);
Assert.True(seq.HasCurrentNode);
}
[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()
{
// R2-Q4: GetObjectSequence gates Branch 2 (cyclic substates) on the
// 0x40000000 class bit and Branch 1 (style change) on the top bit —
// bare low-word ids like the pre-cutover 0x0003/0x0005 never satisfy
// those gates and the dispatch silently fails. Tag Style/IdleMotion/
// WalkMotion with their class bits (masking to the low 24 bits for
// the cycle/link key hash is unaffected — CMotionTable keys on
// `id & 0xFFFFFF`).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000012u;
const uint CycleAnim = 0x03000010u;
const uint LinkAnim = 0x03000011u;
var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
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);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) —
// route it at IdleMotion (the state this test "was already playing"
// before priming) and give IdleMotion its own cycle so the real
// SetCycle(Style, IdleMotion) priming call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f);
var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
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 — a
// real SetCycle call now that CurrentStyle/CurrentMotion are
// read-only mirrors of MotionState (R2-Q4; reflection SetValue no
// longer works, "Property set method not found").
seq.SetCycle(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 Advance_LinkTailDoesNotBlendIntoLinkFrame0()
{
// Issue #61 regression: the fractional tail of a non-looping LINK
// (the ~30 ms between the last integer frame and the wrap boundary)
// must hold the link's END pose instead of blending into the link's
// FRAME 0 pose. Old behaviour: BuildBlendedFrame wrapped nextIdx to
// rangeLo unconditionally, producing a one-frame flash through the
// link's starting pose at the link→cycle boundary. Symptoms: door
// swing-open flap (frame 0 = closed); player run-stop twitch
// (frame 0 = mid-stride).
// R2-Q4: class-bit-tagged ids (GetObjectSequence gates Branch 1/2 on
// the 0x80000000/0x40000000 bits) — masking to the low 24 bits for
// the cycle/link key hash is unaffected.
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000082u;
const uint CycleAnim = 0x03000080u;
const uint LinkAnim = 0x03000081u;
// Link anim: 3 frames, distinct Y so we can tell which frame is being
// sampled. Frame 0 Y=10 (link's starting pose — e.g. closed door),
// frame 2 Y=0 (link's end pose — e.g. open door).
var linkAnim = new Animation();
for (int f = 0; f < 3; f++)
{
var pf = new AnimationFrame(1);
float y = 10f - 5f * f; // 10, 5, 0
pf.Frames.Add(new Frame { Origin = new Vector3(0, y, 0), Orientation = Quaternion.Identity });
linkAnim.PartFrames.Add(pf);
}
// Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose).
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
var idleAnim = Fixtures.MakeAnim(1, 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: 30f);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion (the state we prime through below) with
// its own cycle so the priming SetCycle call actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f);
var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
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 — a
// real SetCycle call (reflection SetValue no longer works against
// the now-read-only CurrentStyle/CurrentMotion mirrors).
seq.SetCycle(Style, IdleMotion);
seq.SetCycle(Style, WalkMotion);
// Advance to _framePosition ≈ 2.5 — past the last integer frame (2)
// but before maxBoundary - epsilon (≈ 3). At 30 fps, 2.5/30 = 0.0833s.
seq.Advance(0.0833f);
double pos = GetFramePosition(seq);
Assert.InRange(pos, 2.4, 2.7);
var transforms = seq.Advance(0.0001f); // tiny extra dt to trigger blend read
// Pre-fix: nextIdx would wrap to rangeLo (0), so transforms[0].Origin.Y
// would land near 0.5 × 0 + 0.5 × 10 = 5 (mid-blend with link frame 0).
// Post-fix: nextIdx = frameIdx (2), so transforms[0].Origin.Y = 0 (held).
Assert.True(transforms[0].Origin.Y < 1f,
$"Link tail should hold last-frame pose Y=0; got Y={transforms[0].Origin.Y} "
+ "(would be ~5 if nextIdx still wrapped to link frame 0)");
}
[Fact]
public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink()
{
// R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
// S6_WalkBackToReady_StopSettleFallback): the pre-cutover adapter had
// an invented "stop-anim low-byte fallback" that re-keyed a
// WalkForward->Ready link when a WalkBackward->Ready lookup missed.
// Retail has no such fallback — CMotionTable.GetLink's verbatim
// reversed-key branch (Q0-pins A1) does the real job: adjust_motion
// remaps WalkBackward to WalkForward with a NEGATIVE SubstateMod, so
// stopping from it drives GetLink's "either speed negative -> swapped
// keys" path, which resolves the link stored FROM the style default
// (Ready) TO WalkForward and plays it IN REVERSE (the Ready->Walk
// windup run backward as a settle). The fixture below stores that
// link under Links[(style,Ready)][WalkForward] — the opposite
// direction from the old WalkForward->Ready fallback entry — because
// that's the key GetLink's reversed branch actually probes.
const uint Style = 0x8000003Du;
const uint WalkForwardCmd = 0x40000005u;
const uint WalkBackCmd = 0x40000006u;
const uint ReadyCmd = 0x40000003u;
const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0)
const uint LinkAnim = 0x03000091u; // Ready->Walk windup (Y=7), played reversed as the settle
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
// Table: Ready cycle + Ready->WalkForward windup link (probed
// REVERSED by GetLink's swapped-key branch when settling out of a
// negative-SubstateMod substate). No forward WalkBackward cycle is
// needed — adjust_motion remaps WalkBackward to WalkForward with a
// negated + BackwardsFactor-scaled speed before dispatch ever sees it.
var mt = Fixtures.MakeMtable(
style: Style,
motion: ReadyCmd,
cycleAnimId: CycleAnim,
fromMotion: ReadyCmd,
toMotion: WalkForwardCmd,
linkAnimId: LinkAnim,
framerate: 30f);
// WalkForward also needs a cycle — adjust_motion's WalkBackward remap
// dispatches WalkForward's cycle (with the negated/scaled speed) as
// part of entering "backward" motion below.
int walkKey = (int)((Style << 16) | (WalkForwardCmd & 0xFFFFFFu));
mt.Cycles[walkKey] = Fixtures.MakeMotionData(CycleAnim, framerate: 30f);
var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
// Enter WalkBackward for real — SetCycle's adjust_motion head remaps
// this to WalkForward with SubstateMod = -0.65 (BackwardsFactor),
// which is what makes the SUBSEQUENT stop-to-Ready call route
// GetLink's reversed-key branch.
seq.SetCycle(Style, WalkBackCmd, 1.0f);
seq.SetCycle(Style, ReadyCmd);
// Advance a tiny dt — should land on the reversed windup link
// (Y=7), not the Ready cycle (Y=0).
var transforms = seq.Advance(0.001f);
Assert.Single(transforms);
Assert.True(transforms[0].Origin.Y > 5f,
$"Stop-from-backward should resolve GetLink's reversed-key branch "
+ $"(expect Y≈7 from the reversed windup link); got Y={transforms[0].Origin.Y} "
+ "(Y=0 means the link didn't resolve and we snapped to the Ready cycle).");
}
[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.
// R2-Q4: Style needs the 0x80000000 top bit and TurnRight/TurnLeft the
// 0x40000000 cycle-class bit — GetObjectSequence's entry/branch gates
// (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity) test the
// FULL command word, not just the low 16 bits adjust_motion remaps.
const uint Style = 0x8000003Du; // NonCombat
const uint TurnRight = 0x4045000Du; // bit pattern for TurnRight in NonCombat
const uint TurnLeft = 0x4045000Eu; // 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;
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route the default straight at TurnRight (the only cycle this
// fixture defines) so initialize_state's baseline install succeeds
// and state.Style/Substate are non-zero before the explicit dispatch.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)TurnRight;
// 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);
// R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
// S5_WalkBackward_RemapNegativeSpeed's "mirrors only" diff):
// CurrentMotion is now a GET-ONLY mirror of MotionState.Substate,
// and MotionState owns the POST-adjust_motion substate — retail's
// interpreted state IS the adjusted one (TurnRight played reversed),
// not the raw TurnLeft the caller passed in. Pre-cutover the adapter
// kept its own separate CurrentMotion field and never overwrote it
// with the adjusted id; that field no longer exists.
Assert.Equal(TurnRight, seq.CurrentMotion);
Assert.Equal(-1f, seq.CurrentSpeedMod, 3);
// R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated
// epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's
// AnimSequenceNode.GetStartingFrame (0x00525c80) returns a BARE INT
// for reverse playback: HighFrame + 1 (gap map G1 — "NO epsilon").
// LowFrame=0, HighFrame=3 (no swap at append time; the swap only
// happens inside MultiplyFramerate for an in-place resign, which
// this path doesn't take), so the retail-exact start position is
// exactly 4.0, not "near but under" 4.0. The cursor starts at the
// boundary and counts DOWN toward LowFrame(=0) on the next Advance.
double pos = GetFramePosition(seq);
Assert.True(pos == 4.0,
$"Expected framePosition == 4 (bare-int reverse start = HighFrame+1, "
+ $"retail AnimSequenceNode.GetStartingFrame 0x00525c80 has NO epsilon — G1); got {pos}");
}
[Fact]
public void Advance_NegativeSpeed_FramePositionDecreases()
{
// Verify that a cycle loaded with negative framerate counts downward.
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
// 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.
// R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000082u;
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 idleAnim = Fixtures.MakeAnim(1, 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);
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion with its own cycle so the priming
// SetCycle call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
// Prime as if already playing IdleMotion — real SetCycle call
// (reflection SetValue no longer works, see WithTransitionLink test).
seq.SetCycle(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.
// R2-Q4: class-bit-tagged ids + retail-mandatory StyleDefaults — the bare
// ids made this test pass VACUOUSLY (dispatch silently failed, no anim
// played, so "hook did not fire" held for the wrong reason).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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.
// 0.25s at -10fps = -2.5 frames → crosses the 3→2 boundary, so the
// hooked frame IS reached (same advance as the Backward sibling test
// — the direction filter, not distance, is what's under test).
seq.Advance(0.25f);
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()
{
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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 (R1-P6: the wired Frame path, gap map G7) ───────
[Fact]
public void Advance_WithRootMotionFrame_AccumulatesPosFrameDeltas()
{
// R1-P6 (2026-07-02): root motion flows through retail's actual
// contract — CSequence::update(quantum, Frame*) (0x00525b80):
// every crossed integer frame combines the node's pos_frame into
// the caller-supplied Frame (update_internal 0x005255d0). The old
// adapter-side accumulator (ConsumeRootMotionDelta) is DELETED —
// this is the seam R6's per-tick order consumes.
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);
// Advance 0.25s @10fps → 2.5 frames → 2 crossings (0→1, 1→2), each
// combining +1 X of pos_frame origin into the supplied Frame.
var rootFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
seq.Advance(0.25f, rootFrame);
Assert.True(rootFrame.Origin.X >= 1.8f && rootFrame.Origin.X <= 2.2f,
$"Expected ~2.0 root motion X after 2 crossings via the wired Frame, got {rootFrame.Origin.X}");
}
[Fact]
public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity()
{
// MotionData with HasVelocity flag should surface via CurrentVelocity.
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) —
// without it, initialize_state's SetDefaultState fails, state.Style
// stays 0, and GetObjectSequence's entry guard rejects every
// dispatch. Route the default straight at Motion (the cycle this
// test cares about).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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()
{
// R2-Q4: retail-mandatory StyleDefaults, and Motion needs its
// 0x40000000 cycle-class bit — the same-motion re-speed fast path
// (Branch 2, target==substate) still requires the class-bit gate to
// be reached in the first place.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000003u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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).
// R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000132u;
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 idleAnim = Fixtures.MakeAnim(1, 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);
// R2-Q4: retail-mandatory StyleDefaults — route it at IdleMotion with
// its own cycle so the priming SetCycle call below actually dispatches.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
// Prime as if already playing IdleMotion — real SetCycle call.
seq.SetCycle(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).
// R2-Q4: Motion needs the 0x40000000 cycle-class bit — GetObjectSequence
// Branch 2 (and its same-motion fast re-speed path) never triggers on
// a bare low-word id (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u; // 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;
// R2-Q4: the dispatch stack needs the retail-mandatory StyleDefaults
// entry (SetDefaultState 0x005230a0 requires StyleDefaults[DefaultStyle];
// GetObjectSequence refuses a zero style/substate). Real dat tables
// always carry it.
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
// R2-Q4: halve the playback rate via the retail same-motion re-speed
// (GetObjectSequence Branch-2 fast path: change_cycle_speed +
// subtract_motion(old) + combine_motion(new), decomp §5) — the old
// MultiplyCyclicFramerate adapter composite is deleted.
seq.SetCycle(Style, Motion, speedMod: 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)
// (subtract_motion(1.0) + combine_motion(0.5) = ×0.5 net).
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.
// R2-Q4: class-bit-tagged ids — the bare ids made this test pass
// VACUOUSLY (dispatch silently failed; "cursor unchanged" held
// because nothing moved at all).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
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);
// R2-Q4: retail-mandatory StyleDefaults entry (see
// MultiplyCyclicFramerate_HalvesPlaybackRate).
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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);
// R2-Q4: mid-cycle re-speed via the retail Branch-2 fast path — must
// not touch the cursor (change_cycle_speed scales framerates only).
seq.SetCycle(Style, Motion, speedMod: 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.
// R2-Q4: Motion needs the 0x40000000 cycle-class bit — see
// CurrentVelocity_ExposedFromMotionData_WhenHasVelocity.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
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.
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
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.
// R2-Q4: Motion needs the 0x40000000 cycle-class bit — see
// CurrentVelocity_ExposedFromMotionData_WhenHasVelocity.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
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 CurrentOmega_ReflectsMotionDataOmega()
{
// A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw)
// should surface as CurrentOmega = (0, 0, 1) after SetCycle.
// Scales with speedMod exactly like Velocity.
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;
const uint Motion = 0x4000000Du; // TurnRight
const uint AnimId = 0x03000701u;
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));
var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) };
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: 2f);
// Omega scales by speedMod — 1.0 × 2 = 2 rad/sec.
Assert.Equal(2.0f, seq.CurrentOmega.Z, 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.
// R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x40000003u;
const uint WalkMotion = 0x40000005u;
const uint IdleAnim = 0x03000603u;
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 idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
// R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0)
// — route it at IdleMotion (the state we prime through below).
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f);
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(IdleAnim, idleAnim);
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
// Prime as if already playing IdleMotion — real SetCycle call.
seq.SetCycle(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.
// R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (SetDefaultState 0x005230a0 is retail-mandatory — GetObjectSequence
// refuses style==0/substate==0, see
// CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Eu; // 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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
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_ActionSurvivesImmediateReadyCycleEcho()
{
// ACE broadcasts creature attacks as Action-class ForwardCommand
// values followed by Ready. Retail keeps currState.Substate at Ready
// while the action link drains, so the Ready echo must not abort the
// in-flight swing.
// R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
const uint IdleMotion = 0x41000003u;
const uint AttackMotion = 0x10000052u;
const uint IdleAnimId = 0x03000503u;
const uint AttackAnimId = 0x03000504u;
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(AttackMotion);
seq.SetCycle(Style, IdleMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(12f, fr[0].Origin.X, 1);
Assert.Equal(IdleMotion, seq.CurrentMotion);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{
// R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests.
// S9_TurnModifier): a Modifier-class command (mask 0x20) — like Jump
// (0x2500003B) or a turn-while-moving overlay — resolves from the
// Modifiers dict, first with style-specific key then with unstyled
// fallback (CMotionTable.GetObjectSequence Branch 4). Pre-cutover the
// adapter INSERTED the modifier's anim before the cyclic tail — an
// acdream invention. Retail Branch 4 is PHYSICS-ONLY combine_motion:
// the base cycle's anim list is untouched (no new nodes), and the
// modifier contributes velocity/omega on top of the cycle's own,
// tracked on the MotionState modifier stack (AP-73 mechanism).
// R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Modifier: (Style, Jump) — carries an omega contribution (a jump
// kick's angular nudge) rather than an anim payload, since Branch 4
// never touches the anim list.
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
var jumpMd = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
jumpMd.Flags = MotionDataFlags.HasOmega;
jumpMd.Omega = new Vector3(0f, 0f, 2.5f);
mt.Modifiers[modKey] = jumpMd;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(JumpAnimId, jumpAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
int queueBefore = seq.QueueCount;
seq.PlayAction(JumpMotion);
// No anim nodes inserted — the queue is unchanged from before the
// modifier fired.
Assert.Equal(queueBefore, seq.QueueCount);
// The modifier's omega is combined onto the sequence's physics.
Assert.Equal(2.5f, seq.CurrentOmega.Z, 3);
}
[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.
// R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry
// (see PlayAction_Action_ResolvesFromLinksDict).
const uint Style = 0x8000003Du;
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;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion;
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 the core CSequence's FrameNumber via reflection (test-only).
/// R1-P5 rehost (2026-07-02): _framePosition lived directly on
/// AnimationSequencer pre-cutover; it now lives on the private _core
/// (CSequence) field as the public FrameNumber. Two-hop reflection:
/// grab _core, then its FrameNumber field.
/// </summary>
private static double GetFramePosition(AnimationSequencer seq)
{
var coreField = typeof(AnimationSequencer)
.GetField("_core",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
var core = coreField?.GetValue(seq);
if (core is null) return -1.0;
var frameNumberField = core.GetType()
.GetField("FrameNumber",
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.Instance);
return frameNumberField is null ? -1.0 : (double)frameNumberField.GetValue(core)!;
}
/// <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);
}
}