refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port
Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct translation guide. Every key algorithmic difference from the previous patched implementation is addressed: 1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail client binary. Previously float, which accumulated rounding error over long sessions. 2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time: negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout. 3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both forward and reverse; boundary detection uses EndFrame as the lower bound for reverse playback (matching the post-swap field semantics); remainder time propagates correctly across node boundaries for large dt values. 4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0) formulas are now correct: negative speed starts at (EndFrame+1)-epsilon, ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon. 5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the linked list is exhausted, matching the retail loop-forever semantics. 6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed), WalkBackward→WalkForward (negate×0.65 BackwardsFactor). 7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the existing implementation is correct. AnimationSequencerTests grows from 9 to 17 tests: - Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1 - Reverse frame position decreases (not increases) over time - Reverse wrap at start boundary recovers and loops - advance_to_next_animation: link node drains then enters cycle - Cycle loops repeatedly without crash or position drift All 431 tests green (109 net + 322 core). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8402aee703
commit
78aef6d575
2 changed files with 478 additions and 145 deletions
|
|
@ -30,16 +30,17 @@ public sealed class DatCollectionLoader : IAnimationLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// AnimationSequencer — per-entity animation playback with transition links.
|
// AnimationSequencer — faithful port of the decompiled retail AC client
|
||||||
|
// animation system.
|
||||||
//
|
//
|
||||||
// Decompiled references:
|
// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md):
|
||||||
// FUN_005360d0 (chunk_00530000.c:4799) — quaternion slerp with dot-product
|
// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed
|
||||||
// sign-flip and lerp fallback for near-parallel quaternions.
|
// FUN_005261D0 — update_internal: the core per-frame advance loop
|
||||||
// Sequence.update_internal (ACE Sequence.cs) — frame advance: frameNum +=
|
// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic
|
||||||
// framerate*dt, test against high/low, fire hooks at each crossed
|
// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent)
|
||||||
// integer frame boundary, advance to next anim when done.
|
// FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent)
|
||||||
// MotionTable.get_link (ACE MotionTable.cs:395) — transition lookup:
|
// FUN_005360d0 — quaternion slerp with dot-product sign-flip
|
||||||
// Links[(style<<16)|(fromSubstate&0xFFFFFF)].TryGetValue(toMotion).
|
// MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping
|
||||||
//
|
//
|
||||||
// DatReaderWriter types used:
|
// DatReaderWriter types used:
|
||||||
// MotionTable.Links : Dictionary<int, MotionCommandData>
|
// MotionTable.Links : Dictionary<int, MotionCommandData>
|
||||||
|
|
@ -71,32 +72,84 @@ public readonly struct PartTransform
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One entry in the animation queue (link transition or looping cycle).
|
/// One entry in the animation queue (link transition or looping cycle).
|
||||||
|
///
|
||||||
|
/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18.
|
||||||
|
/// When speedScale < 0, startFrame and endFrame are swapped at construction
|
||||||
|
/// time (FUN_005267E0 / multiply_framerate) so the advance loop always has:
|
||||||
|
/// forward: startFrame ≤ endFrame (framePosition counts up)
|
||||||
|
/// reverse: startFrame ≥ endFrame (framePosition counts down)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class AnimNode
|
internal sealed class AnimNode
|
||||||
{
|
{
|
||||||
public Animation Anim;
|
public Animation Anim;
|
||||||
public float Framerate; // signed; negative means reverse
|
public double Framerate; // signed; negative means reverse playback
|
||||||
public int LowFrame;
|
public int StartFrame; // inclusive start frame (post-swap for negative speed)
|
||||||
public int HighFrame;
|
public int EndFrame; // inclusive end frame (post-swap for negative speed)
|
||||||
public bool IsLooping; // true only for the tail cyclic node
|
public bool IsLooping; // true only for the tail cyclic node
|
||||||
|
|
||||||
public AnimNode(Animation anim, float framerate, int lowFrame, int highFrame, bool isLooping)
|
public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
|
||||||
{
|
{
|
||||||
Anim = anim;
|
Anim = anim;
|
||||||
Framerate = framerate;
|
Framerate = framerate;
|
||||||
LowFrame = lowFrame;
|
StartFrame = startFrame;
|
||||||
HighFrame = highFrame;
|
EndFrame = endFrame;
|
||||||
IsLooping = isLooping;
|
IsLooping = isLooping;
|
||||||
}
|
}
|
||||||
|
|
||||||
public float StartingFrame => Framerate >= 0f ? LowFrame : HighFrame + 1 - 1e-5f;
|
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
|
||||||
public float EndingFrame => Framerate >= 0f ? HighFrame + 1 - 1e-5f : LowFrame;
|
// Returns the initial framePosition cursor for this node.
|
||||||
|
// speedScale >= 0 → (double)startFrame
|
||||||
|
// speedScale < 0 → (double)(endFrame + 1) - EPSILON
|
||||||
|
// EPSILON = _DAT_007c92b4 (a tiny float just below the boundary)
|
||||||
|
public double GetStartFramePosition()
|
||||||
|
{
|
||||||
|
if (Framerate >= 0.0)
|
||||||
|
return (double)StartFrame;
|
||||||
|
else
|
||||||
|
return (double)(EndFrame + 1) - FrameEpsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FUN_005268B0 — GetEndFramePosition ───────────────────────────────
|
||||||
|
// Returns where the cursor sits when this node is exhausted.
|
||||||
|
// speedScale >= 0 → (double)(endFrame + 1) - EPSILON
|
||||||
|
// speedScale < 0 → (double)startFrame
|
||||||
|
public double GetEndFramePosition()
|
||||||
|
{
|
||||||
|
if (Framerate >= 0.0)
|
||||||
|
return (double)(EndFrame + 1) - FrameEpsilon;
|
||||||
|
else
|
||||||
|
return (double)StartFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small double constant matching _DAT_007c92b4 in the retail binary.
|
||||||
|
// Used to position the cursor just before a frame boundary.
|
||||||
|
private const double FrameEpsilon = 1e-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full animation playback engine for one entity.
|
/// Full animation playback engine for one entity.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// This is a faithful port of the retail AC client's Sequence object
|
||||||
|
/// (docs/research/acclient_animation_pseudocode.md, sections 5–7).
|
||||||
|
/// Key invariants:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>_framePosition</c> is a <c>double</c> matching the retail client's
|
||||||
|
/// 64-bit field at Sequence+0x30.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Negative framerate means reverse playback; startFrame/endFrame are
|
||||||
|
/// swapped at node construction time (FUN_005267E0).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// When a node's frames are exhausted, <c>advance_to_next_animation</c>
|
||||||
|
/// wraps to <c>_firstCyclic</c> (the looping tail of the queue).
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
/// Usage pattern:
|
/// Usage pattern:
|
||||||
/// <code>
|
/// <code>
|
||||||
/// var seq = new AnimationSequencer(setup, motionTable, dats);
|
/// var seq = new AnimationSequencer(setup, motionTable, dats);
|
||||||
|
|
@ -106,13 +159,6 @@ internal sealed class AnimNode
|
||||||
/// // rebuild MeshRefs from transforms
|
/// // rebuild MeshRefs from transforms
|
||||||
/// </code>
|
/// </code>
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// When <see cref="SetCycle"/> is called with a new motion, the sequencer
|
|
||||||
/// looks up a transition link in the MotionTable and prepends those frames
|
|
||||||
/// to the queue so the entity blends smoothly instead of snapping. The
|
|
||||||
/// cyclic tail of the queue loops forever.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AnimationSequencer
|
public sealed class AnimationSequencer
|
||||||
{
|
{
|
||||||
|
|
@ -134,9 +180,13 @@ public sealed class AnimationSequencer
|
||||||
private readonly LinkedList<AnimNode> _queue = new();
|
private readonly LinkedList<AnimNode> _queue = new();
|
||||||
private LinkedListNode<AnimNode>? _currNode;
|
private LinkedListNode<AnimNode>? _currNode;
|
||||||
private LinkedListNode<AnimNode>? _firstCyclic;
|
private LinkedListNode<AnimNode>? _firstCyclic;
|
||||||
private float _frameNum;
|
|
||||||
|
|
||||||
private const float Epsilon = 1e-5f;
|
// 64-bit fractional frame position — matches Sequence+0x30 in the retail client.
|
||||||
|
// Named _framePosition to distinguish it from the old float _frameNum.
|
||||||
|
private double _framePosition;
|
||||||
|
|
||||||
|
private const double FrameEpsilon = 1e-5;
|
||||||
|
private const double RateEpsilon = 1e-6;
|
||||||
|
|
||||||
// ── Constructor ──────────────────────────────────────────────────────────
|
// ── Constructor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -166,33 +216,39 @@ public sealed class AnimationSequencer
|
||||||
/// Switch to a new cyclic motion, prepending any transition link frames
|
/// Switch to a new cyclic motion, prepending any transition link frames
|
||||||
/// so the switch is smooth. If the motion table has no link for the
|
/// so the switch is smooth. If the motion table has no link for the
|
||||||
/// (currentStyle, currentMotion) → newMotion transition, the cycle
|
/// (currentStyle, currentMotion) → newMotion transition, the cycle
|
||||||
/// switches immediately (same as the old snap behaviour).
|
/// switches immediately.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Implements <c>adjust_motion</c> (ACE MotionInterp.cs:394-428): the AC
|
||||||
|
/// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward.
|
||||||
|
/// These are played as their right-side / forward equivalents with a
|
||||||
|
/// negated framerate so the animation runs in reverse.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
|
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
|
||||||
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
|
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
|
||||||
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
|
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
|
||||||
public void SetCycle(uint style, uint motion, float speedMod = 1f)
|
public void SetCycle(uint style, uint motion, float speedMod = 1f)
|
||||||
{
|
{
|
||||||
// ── adjust_motion: remap left→right variants with negative speed ───
|
// ── adjust_motion: remap left→right / backward→forward variants ───
|
||||||
// The AC client's MotionTable has NO cycles for TurnLeft, SideStepLeft,
|
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
|
||||||
// or WalkBackward. These are played as their right-side / forward
|
// SideStepLeft, or WalkBackward cycles; the client plays the mirror
|
||||||
// equivalents with negative framerate (animation runs backward).
|
// animation with a negated speed so it runs backward.
|
||||||
// ACE: MotionInterp.cs:394-428
|
|
||||||
uint adjustedMotion = motion;
|
uint adjustedMotion = motion;
|
||||||
float adjustedSpeed = speedMod;
|
float adjustedSpeed = speedMod;
|
||||||
switch (motion & 0xFFFFu)
|
switch (motion & 0xFFFFu)
|
||||||
{
|
{
|
||||||
case 0x000E: // TurnLeft → TurnRight
|
case 0x000E: // TurnLeft → TurnRight (negate speed)
|
||||||
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
|
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
|
||||||
adjustedSpeed *= -1f;
|
adjustedSpeed = -speedMod;
|
||||||
break;
|
break;
|
||||||
case 0x0010: // SideStepLeft → SideStepRight
|
case 0x0010: // SideStepLeft → SideStepRight (negate speed)
|
||||||
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
|
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
|
||||||
adjustedSpeed *= -1f;
|
adjustedSpeed = -speedMod;
|
||||||
break;
|
break;
|
||||||
case 0x0006: // WalkBackward → WalkForward
|
case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor)
|
||||||
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
|
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
|
||||||
adjustedSpeed *= -0.65f; // BackwardsFactor from ACE
|
adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +262,7 @@ public sealed class AnimationSequencer
|
||||||
? GetLink(style, CurrentMotion, adjustedMotion)
|
? GetLink(style, CurrentMotion, adjustedMotion)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Resolve target cycle using the ADJUSTED motion (TurnRight, not TurnLeft).
|
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
|
||||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||||
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
||||||
|
|
||||||
|
|
@ -228,15 +284,13 @@ public sealed class AnimationSequencer
|
||||||
// No cycle and no link — nothing to play; reset fully.
|
// No cycle and no link — nothing to play; reset fully.
|
||||||
_currNode = null;
|
_currNode = null;
|
||||||
_firstCyclic = null;
|
_firstCyclic = null;
|
||||||
_frameNum = 0f;
|
_framePosition = 0.0;
|
||||||
CurrentStyle = style;
|
CurrentStyle = style;
|
||||||
CurrentMotion = motion;
|
CurrentMotion = motion;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the first cyclic node (the tail after all link frames).
|
// Mark the first cyclic node (the looping tail after all link frames).
|
||||||
// If there were no link frames, the first enqueued node is cyclic.
|
|
||||||
// Re-scan from the end to find the first IsLooping node.
|
|
||||||
_firstCyclic = null;
|
_firstCyclic = null;
|
||||||
for (var n = _queue.First; n != null; n = n.Next)
|
for (var n = _queue.First; n != null; n = n.Next)
|
||||||
{
|
{
|
||||||
|
|
@ -251,7 +305,7 @@ public sealed class AnimationSequencer
|
||||||
if (_currNode == null)
|
if (_currNode == null)
|
||||||
{
|
{
|
||||||
_currNode = _queue.First;
|
_currNode = _queue.First;
|
||||||
_frameNum = _currNode?.Value.StartingFrame ?? 0f;
|
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrentStyle = style;
|
CurrentStyle = style;
|
||||||
|
|
@ -263,10 +317,15 @@ public sealed class AnimationSequencer
|
||||||
/// per-part transforms for the current blended keyframe.
|
/// per-part transforms for the current blended keyframe.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0) in a
|
||||||
|
/// simplified form: no frame-trigger events (PhysicsObject not modelled
|
||||||
|
/// here), but correct boundary detection, remainder propagation, and
|
||||||
|
/// advance_to_next_animation wrapping.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
/// The slerp algorithm mirrors the decompiled retail client's
|
/// The slerp algorithm mirrors the decompiled retail client's
|
||||||
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799):
|
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
|
||||||
/// compute dot product; if negative, negate q2 and dot; if the angle is
|
|
||||||
/// very small, fall back to linear (1-t, t) instead of sin-based slerp.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dt">Elapsed time in seconds since the last call.</param>
|
/// <param name="dt">Elapsed time in seconds since the last call.</param>
|
||||||
|
|
@ -281,42 +340,73 @@ public sealed class AnimationSequencer
|
||||||
if (_currNode == null || dt <= 0f)
|
if (_currNode == null || dt <= 0f)
|
||||||
return BuildIdentityFrame(partCount);
|
return BuildIdentityFrame(partCount);
|
||||||
|
|
||||||
var curr = _currNode.Value;
|
// ── update_internal (FUN_005261D0) ───────────────────────────────
|
||||||
float framerate = curr.Framerate;
|
// Loop because a large dt can exhaust multiple nodes sequentially.
|
||||||
float frametime = framerate * dt;
|
double timeRemaining = (double)dt;
|
||||||
|
|
||||||
bool animDone = false;
|
while (timeRemaining > 0.0 && _currNode != null)
|
||||||
float timeRemainder = 0f;
|
|
||||||
|
|
||||||
_frameNum += frametime;
|
|
||||||
|
|
||||||
if (frametime > 0f)
|
|
||||||
{
|
{
|
||||||
if (_frameNum > curr.HighFrame + 1 - Epsilon)
|
var curr = _currNode.Value;
|
||||||
{
|
double rate = curr.Framerate; // signed (negative = reverse)
|
||||||
timeRemainder = Math.Abs(framerate) > Epsilon
|
double delta = rate * timeRemaining;
|
||||||
? (_frameNum - (curr.HighFrame + 1 - Epsilon)) / framerate
|
|
||||||
: 0f;
|
|
||||||
_frameNum = curr.HighFrame;
|
|
||||||
animDone = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (frametime < 0f)
|
|
||||||
{
|
|
||||||
if (_frameNum < curr.LowFrame)
|
|
||||||
{
|
|
||||||
timeRemainder = Math.Abs(framerate) > Epsilon
|
|
||||||
? (_frameNum - curr.LowFrame) / framerate
|
|
||||||
: 0f;
|
|
||||||
_frameNum = curr.LowFrame;
|
|
||||||
animDone = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animDone)
|
if (Math.Abs(delta) < RateEpsilon)
|
||||||
|
break; // rate ≈ 0 — nothing to do
|
||||||
|
|
||||||
|
double newPos = _framePosition + delta;
|
||||||
|
bool wrapped = false;
|
||||||
|
double overflow = 0.0;
|
||||||
|
|
||||||
|
if (delta > 0.0)
|
||||||
|
{
|
||||||
|
// ── FORWARD PLAYBACK ──────────────────────────────────────
|
||||||
|
// End boundary = endFrame + 1. Pseudocode: floor(newPos) > maxFrame.
|
||||||
|
double maxBoundary = (double)(curr.EndFrame + 1);
|
||||||
|
if (newPos >= maxBoundary - FrameEpsilon)
|
||||||
|
{
|
||||||
|
// How much time spilled past the boundary?
|
||||||
|
overflow = (newPos - maxBoundary) / rate;
|
||||||
|
if (overflow < 0.0) overflow = 0.0;
|
||||||
|
|
||||||
|
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
|
||||||
|
wrapped = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_framePosition = newPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ── REVERSE PLAYBACK ─────────────────────────────────────
|
||||||
|
// After FUN_005267E0 swaps low↔high for negative speed:
|
||||||
|
// StartFrame = high (e.g. 3), EndFrame = low (e.g. 0)
|
||||||
|
// GetStartFramePosition placed cursor at (EndFrame+1)-eps ≈ 0.99999.
|
||||||
|
// The cursor counts DOWN toward EndFrame. Boundary = EndFrame.
|
||||||
|
double minBoundary = (double)curr.EndFrame;
|
||||||
|
if (newPos <= minBoundary)
|
||||||
|
{
|
||||||
|
// How much time spilled past the lower boundary?
|
||||||
|
overflow = (newPos - minBoundary) / rate;
|
||||||
|
if (overflow < 0.0) overflow = 0.0;
|
||||||
|
|
||||||
|
_framePosition = minBoundary; // clamp to lower boundary
|
||||||
|
wrapped = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_framePosition = newPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wrapped)
|
||||||
|
break; // consumed all dt without hitting boundary — done
|
||||||
|
|
||||||
|
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
|
||||||
AdvanceToNextAnimation();
|
AdvanceToNextAnimation();
|
||||||
|
timeRemaining = overflow; // continue with leftover time
|
||||||
|
}
|
||||||
|
|
||||||
// Build the blended frame.
|
|
||||||
return BuildBlendedFrame();
|
return BuildBlendedFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +419,7 @@ public sealed class AnimationSequencer
|
||||||
_queue.Clear();
|
_queue.Clear();
|
||||||
_currNode = null;
|
_currNode = null;
|
||||||
_firstCyclic = null;
|
_firstCyclic = null;
|
||||||
_frameNum = 0f;
|
_framePosition = 0.0;
|
||||||
CurrentStyle = 0;
|
CurrentStyle = 0;
|
||||||
CurrentMotion = 0;
|
CurrentMotion = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +430,7 @@ public sealed class AnimationSequencer
|
||||||
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
|
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
|
||||||
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
|
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
|
||||||
///
|
///
|
||||||
/// Port of ACE's MotionTable.get_link (positive-speed path):
|
/// Port of ACE's MotionTable.get_link:
|
||||||
/// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion]
|
/// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion]
|
||||||
/// 2. Fallback: try Links[style<<16][toMotion]
|
/// 2. Fallback: try Links[style<<16][toMotion]
|
||||||
///
|
///
|
||||||
|
|
@ -370,7 +460,10 @@ public sealed class AnimationSequencer
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
|
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
|
||||||
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
|
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
|
||||||
/// Mirrors ACE AnimSequenceNode.set_animation_id.
|
///
|
||||||
|
/// Implements <c>FUN_005267E0</c> (multiply_framerate): when
|
||||||
|
/// <c>fr < 0</c>, startFrame and endFrame are swapped so the advance
|
||||||
|
/// loop's boundary logic works uniformly for both directions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
|
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
|
||||||
{
|
{
|
||||||
|
|
@ -381,32 +474,34 @@ public sealed class AnimationSequencer
|
||||||
if (anim is null || anim.PartFrames.Count == 0) return null;
|
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||||||
|
|
||||||
int numFrames = anim.PartFrames.Count;
|
int numFrames = anim.PartFrames.Count;
|
||||||
int low = ad.LowFrame;
|
int low = ad.LowFrame;
|
||||||
int high = ad.HighFrame;
|
int high = ad.HighFrame;
|
||||||
|
|
||||||
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
|
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
|
||||||
if (high < 0) high = numFrames - 1;
|
if (high < 0) high = numFrames - 1;
|
||||||
if (low >= numFrames) low = numFrames - 1;
|
if (low >= numFrames) low = numFrames - 1;
|
||||||
if (high >= numFrames) high = numFrames - 1;
|
if (high >= numFrames) high = numFrames - 1;
|
||||||
if (low < 0) low = 0;
|
if (low < 0) low = 0;
|
||||||
|
|
||||||
float fr = ad.Framerate * speedMod;
|
double fr = (double)ad.Framerate * (double)speedMod;
|
||||||
|
|
||||||
// multiply_framerate: when speed is negative (TurnLeft, SideStepLeft),
|
// ── FUN_005267E0 multiply_framerate ──────────────────────────────
|
||||||
// swap Low↔High so the animation plays backward. This is exactly what
|
// When speed is negative (TurnLeft→TurnRight, SideStepLeft→SideStepRight),
|
||||||
// the decompiled FUN_005267E0 does. ACE: AnimData.GetFramerate(speed).
|
// swap Low↔High so the advance loop counts DOWN from the swapped EndFrame
|
||||||
// After swap, LowFrame > HighFrame — the Advance loop handles this
|
// toward the swapped StartFrame. The pseudocode says:
|
||||||
// by checking negative frametime against LowFrame (the higher value).
|
// if speedScale < 0: swap startFrame ↔ endFrame
|
||||||
if (fr < 0f)
|
if (fr < 0.0)
|
||||||
{
|
{
|
||||||
(low, high) = (high, low);
|
(low, high) = (high, low);
|
||||||
|
// After swap: StartFrame > EndFrame (the loop detects delta < 0 and
|
||||||
|
// uses StartFrame as the lower boundary to count down toward).
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (low > high) high = low; // only clamp for positive-speed case
|
if (low > high) high = low; // clamp for positive-speed case only
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AnimNode(anim, fr, low, high, isLooping);
|
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -425,8 +520,9 @@ public sealed class AnimationSequencer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remove all cyclic (looping) nodes from the tail of the queue, starting
|
/// Remove all cyclic (looping) nodes from the tail of the queue starting
|
||||||
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain.
|
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain so they
|
||||||
|
/// can drain naturally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ClearCyclicTail()
|
private void ClearCyclicTail()
|
||||||
{
|
{
|
||||||
|
|
@ -436,14 +532,15 @@ public sealed class AnimationSequencer
|
||||||
while (node != null)
|
while (node != null)
|
||||||
{
|
{
|
||||||
var next = node.Next;
|
var next = node.Next;
|
||||||
// If CurrAnim is being removed, jump it to the previous non-cyclic node.
|
// If the active node is being removed, jump it to the preceding
|
||||||
|
// non-cyclic node (or reset if there is none).
|
||||||
if (_currNode == node)
|
if (_currNode == node)
|
||||||
{
|
{
|
||||||
_currNode = node.Previous;
|
_currNode = node.Previous;
|
||||||
if (_currNode != null)
|
if (_currNode != null)
|
||||||
_frameNum = _currNode.Value.EndingFrame;
|
_framePosition = _currNode.Value.GetEndFramePosition();
|
||||||
else
|
else
|
||||||
_frameNum = 0f;
|
_framePosition = 0.0;
|
||||||
}
|
}
|
||||||
_queue.Remove(node);
|
_queue.Remove(node);
|
||||||
node = next;
|
node = next;
|
||||||
|
|
@ -453,28 +550,42 @@ public sealed class AnimationSequencer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Move <see cref="_currNode"/> to the next node in the queue, or loop
|
/// Move <see cref="_currNode"/> to the next node in the queue, or wrap
|
||||||
/// back to <see cref="_firstCyclic"/> if at the end. Mirrors ACE's
|
/// back to <see cref="_firstCyclic"/> when the queue is exhausted.
|
||||||
/// <c>advance_to_next_animation</c>.
|
///
|
||||||
|
/// Implements <c>FUN_00525EB0</c> (Sequence::advance_to_next_animation).
|
||||||
|
/// The retail client walks a doubly-linked list; we mirror that with
|
||||||
|
/// LinkedList.Next plus the _firstCyclic wrap sentinel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void AdvanceToNextAnimation()
|
private void AdvanceToNextAnimation()
|
||||||
{
|
{
|
||||||
if (_currNode == null) return;
|
if (_currNode == null) return;
|
||||||
|
|
||||||
if (_currNode.Next != null)
|
LinkedListNode<AnimNode>? next = _currNode.Next;
|
||||||
_currNode = _currNode.Next;
|
|
||||||
|
if (next != null)
|
||||||
|
{
|
||||||
|
_currNode = next;
|
||||||
|
}
|
||||||
else if (_firstCyclic != null)
|
else if (_firstCyclic != null)
|
||||||
|
{
|
||||||
|
// Wrap to first cyclic node — this is the loop that keeps idle/walk
|
||||||
|
// animations playing forever.
|
||||||
_currNode = _firstCyclic;
|
_currNode = _firstCyclic;
|
||||||
// else: end of non-looping sequence — stay on last frame.
|
}
|
||||||
|
// else: end of a finite non-looping sequence; stay on last node.
|
||||||
|
|
||||||
if (_currNode != null)
|
if (_currNode != null)
|
||||||
_frameNum = _currNode.Value.StartingFrame;
|
_framePosition = _currNode.Value.GetStartFramePosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build the per-part blended transform from the current animation frame.
|
/// Build the per-part blended transform from the current animation frame.
|
||||||
/// Blends between floor(frameNum) and floor(frameNum)+1 using the
|
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using
|
||||||
/// fractional part of frameNum.
|
/// the fractional part of _framePosition.
|
||||||
|
///
|
||||||
|
/// Uses the retail-client slerp (<see cref="SlerpRetailClient"/>) for
|
||||||
|
/// quaternion interpolation and linear lerp for position.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IReadOnlyList<PartTransform> BuildBlendedFrame()
|
private IReadOnlyList<PartTransform> BuildBlendedFrame()
|
||||||
{
|
{
|
||||||
|
|
@ -486,31 +597,32 @@ public sealed class AnimationSequencer
|
||||||
var curr = _currNode.Value;
|
var curr = _currNode.Value;
|
||||||
int numPartFrames = curr.Anim.PartFrames.Count;
|
int numPartFrames = curr.Anim.PartFrames.Count;
|
||||||
|
|
||||||
int frameIdx = (int)Math.Floor(_frameNum);
|
// Clamp frameIndex to valid range.
|
||||||
// For backward playback, LowFrame > HighFrame. Use actual min/max
|
int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame);
|
||||||
// of the two to get a valid range for clamping.
|
int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame);
|
||||||
int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame);
|
rangeHi = Math.Min(rangeHi, numPartFrames - 1);
|
||||||
int rangeHi = Math.Min(Math.Max(curr.LowFrame, curr.HighFrame), numPartFrames - 1);
|
|
||||||
|
int frameIdx = (int)Math.Floor(_framePosition);
|
||||||
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
||||||
|
|
||||||
// Next frame for interpolation: step in the playback direction.
|
// Next frame for interpolation: step in the playback direction.
|
||||||
int nextIdx;
|
int nextIdx;
|
||||||
if (curr.Framerate >= 0f)
|
if (curr.Framerate >= 0.0)
|
||||||
{
|
{
|
||||||
nextIdx = frameIdx + 1;
|
nextIdx = frameIdx + 1;
|
||||||
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
||||||
nextIdx = rangeLo; // wrap forward
|
nextIdx = rangeLo; // wrap forward
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
nextIdx = frameIdx - 1;
|
nextIdx = frameIdx - 1;
|
||||||
if (nextIdx < rangeLo)
|
if (nextIdx < rangeLo)
|
||||||
nextIdx = rangeHi; // wrap backward
|
nextIdx = rangeHi; // wrap backward
|
||||||
}
|
}
|
||||||
|
|
||||||
float t = _frameNum - (float)Math.Floor(_frameNum);
|
// Fractional blend weight (always in [0, 1]).
|
||||||
if (t < 0f) t = 0f;
|
double rawT = _framePosition - Math.Floor(_framePosition);
|
||||||
if (t > 1f) t = 1f;
|
float t = (float)Math.Clamp(rawT, 0.0, 1.0);
|
||||||
|
|
||||||
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
||||||
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
|
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
|
||||||
|
|
@ -585,9 +697,9 @@ public sealed class AnimationSequencer
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
float omega = MathF.Acos(dot);
|
float omega = MathF.Acos(dot);
|
||||||
float sinOmega = MathF.Sin(omega);
|
float sinOmega = MathF.Sin(omega);
|
||||||
float invSin = 1f / sinOmega;
|
float invSin = 1f / sinOmega;
|
||||||
|
|
||||||
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
|
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
|
||||||
float candidate2 = MathF.Sin(t * omega) * invSin;
|
float candidate2 = MathF.Sin(t * omega) * invSin;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||||
|
|
||||||
namespace AcDream.Core.Tests.Physics;
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────<EFBFBD><EFBFBD>───
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// AnimationSequencerTests
|
// AnimationSequencerTests
|
||||||
//
|
//
|
||||||
// All tests run fully offline -- no DatCollection, no disk access.
|
// All tests run fully offline -- no DatCollection, no disk access.
|
||||||
|
|
@ -21,15 +21,18 @@ namespace AcDream.Core.Tests.Physics;
|
||||||
// exactly the code paths we are testing.
|
// exactly the code paths we are testing.
|
||||||
//
|
//
|
||||||
// Covered:
|
// Covered:
|
||||||
// 1. SlerpRetailClient matches System.Numerics slerp for standard cases.
|
// 1. SlerpRetailClient matches System.Numerics slerp for standard cases.
|
||||||
// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc).
|
// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc).
|
||||||
// 3. SlerpRetailClient falls back to linear for near-parallel quaternions.
|
// 3. SlerpRetailClient falls back to linear for near-parallel quaternions.
|
||||||
// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop).
|
// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop).
|
||||||
// 5. Advance at dt=0 returns identity frame (no motion table loaded).
|
// 5. Advance at dt=0 returns identity frame (no motion table loaded).
|
||||||
// 6. SetCycle transitions: link frames are prepended before the target cycle.
|
// 6. SetCycle transitions: link frames are prepended before the target cycle.
|
||||||
// 7. GetLink returns null when MotionTable has no link for the transition.
|
// 7. GetLink returns null when MotionTable has no link for the transition.
|
||||||
// 8. SetCycle with same motion twice is a no-op (fast path).
|
// 8. SetCycle with same motion twice is a no-op (fast path).
|
||||||
// 9. Reset clears all state.
|
// 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>
|
/// <summary>
|
||||||
|
|
@ -60,7 +63,6 @@ file static class Fixtures
|
||||||
var anim = new Animation();
|
var anim = new Animation();
|
||||||
for (int f = 0; f < numFrames; f++)
|
for (int f = 0; f < numFrames; f++)
|
||||||
{
|
{
|
||||||
// AnimationFrame requires NumParts in its constructor.
|
|
||||||
var pf = new AnimationFrame((uint)numParts);
|
var pf = new AnimationFrame((uint)numParts);
|
||||||
for (int p = 0; p < numParts; p++)
|
for (int p = 0; p < numParts; p++)
|
||||||
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
|
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
|
||||||
|
|
@ -114,30 +116,30 @@ file static class Fixtures
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static MotionTable MakeMtable(
|
public static MotionTable MakeMtable(
|
||||||
uint style, uint motion, uint cycleAnimId,
|
uint style, uint motion, uint cycleAnimId,
|
||||||
uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0)
|
uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0,
|
||||||
|
float framerate = 30f)
|
||||||
{
|
{
|
||||||
var mt = new MotionTable();
|
var mt = new MotionTable();
|
||||||
mt.DefaultStyle = (DRWMotionCommand)style;
|
mt.DefaultStyle = (DRWMotionCommand)style;
|
||||||
mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
|
mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
|
||||||
|
|
||||||
int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu));
|
int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu));
|
||||||
mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate: 30f);
|
mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate);
|
||||||
|
|
||||||
if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0)
|
if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0)
|
||||||
{
|
{
|
||||||
int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
||||||
var cmd = new MotionCommandData();
|
var cmd = new MotionCommandData();
|
||||||
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate: 30f);
|
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate);
|
||||||
mt.Links[linkOuter] = cmd;
|
mt.Links[linkOuter] = cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mt;
|
return mt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MotionData MakeMotionData(uint animId, float framerate)
|
public static MotionData MakeMotionData(uint animId, float framerate)
|
||||||
{
|
{
|
||||||
var md = new MotionData();
|
var md = new MotionData();
|
||||||
// QualifiedDataId<T> has an implicit conversion from uint.
|
|
||||||
QualifiedDataId<Animation> qid = animId;
|
QualifiedDataId<Animation> qid = animId;
|
||||||
md.Anims.Add(new AnimData
|
md.Anims.Add(new AnimData
|
||||||
{
|
{
|
||||||
|
|
@ -201,7 +203,7 @@ public sealed class AnimationSequencerTests
|
||||||
Assert.Equal(q.W, got.W, 4);
|
Assert.Equal(q.W, got.W, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SetCycle / frame advance ────────────────────────────────────────<EFBFBD><EFBFBD>────
|
// ── SetCycle / frame advance ─────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Advance_NoCycleSet_ReturnsIdentityTransforms()
|
public void Advance_NoCycleSet_ReturnsIdentityTransforms()
|
||||||
|
|
@ -371,12 +373,12 @@ public sealed class AnimationSequencerTests
|
||||||
// Advance a bit to move the frame counter.
|
// Advance a bit to move the frame counter.
|
||||||
seq.Advance(0.1f);
|
seq.Advance(0.1f);
|
||||||
|
|
||||||
float frameBefore = GetFrameNum(seq);
|
double frameBefore = GetFramePosition(seq);
|
||||||
|
|
||||||
// Call SetCycle again with identical args -- fast-path, no reset.
|
// Call SetCycle again with identical args -- fast-path, no reset.
|
||||||
seq.SetCycle(Style, Motion);
|
seq.SetCycle(Style, Motion);
|
||||||
|
|
||||||
float frameAfter = GetFrameNum(seq);
|
double frameAfter = GetFramePosition(seq);
|
||||||
|
|
||||||
Assert.Equal(frameBefore, frameAfter);
|
Assert.Equal(frameBefore, frameAfter);
|
||||||
}
|
}
|
||||||
|
|
@ -412,16 +414,235 @@ public sealed class AnimationSequencerTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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<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");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Expose _frameNum via reflection (test-only).</summary>
|
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>
|
||||||
private static float GetFrameNum(AnimationSequencer seq)
|
private static double GetFramePosition(AnimationSequencer seq)
|
||||||
{
|
{
|
||||||
var field = typeof(AnimationSequencer)
|
var field = typeof(AnimationSequencer)
|
||||||
.GetField("_frameNum",
|
.GetField("_framePosition",
|
||||||
System.Reflection.BindingFlags.NonPublic |
|
System.Reflection.BindingFlags.NonPublic |
|
||||||
System.Reflection.BindingFlags.Instance);
|
System.Reflection.BindingFlags.Instance);
|
||||||
return field is null ? -1f : (float)field.GetValue(seq)!;
|
return field is null ? -1.0 : (double)field.GetValue(seq)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue