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
|
|
@ -13,7 +13,7 @@ using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────<EFBFBD><EFBFBD>───
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AnimationSequencerTests
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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>
|
||||
|
|
@ -60,7 +63,6 @@ file static class Fixtures
|
|||
var anim = new Animation();
|
||||
for (int f = 0; f < numFrames; f++)
|
||||
{
|
||||
// AnimationFrame requires NumParts in its constructor.
|
||||
var pf = new AnimationFrame((uint)numParts);
|
||||
for (int p = 0; p < numParts; p++)
|
||||
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
|
||||
|
|
@ -114,30 +116,30 @@ file static class Fixtures
|
|||
/// </summary>
|
||||
public static MotionTable MakeMtable(
|
||||
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();
|
||||
mt.DefaultStyle = (DRWMotionCommand)style;
|
||||
mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
|
||||
|
||||
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)
|
||||
{
|
||||
int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
||||
var cmd = new MotionCommandData();
|
||||
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate: 30f);
|
||||
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate);
|
||||
mt.Links[linkOuter] = cmd;
|
||||
}
|
||||
|
||||
return mt;
|
||||
}
|
||||
|
||||
private static MotionData MakeMotionData(uint animId, float framerate)
|
||||
public static MotionData MakeMotionData(uint animId, float framerate)
|
||||
{
|
||||
var md = new MotionData();
|
||||
// QualifiedDataId<T> has an implicit conversion from uint.
|
||||
QualifiedDataId<Animation> qid = animId;
|
||||
md.Anims.Add(new AnimData
|
||||
{
|
||||
|
|
@ -201,7 +203,7 @@ public sealed class AnimationSequencerTests
|
|||
Assert.Equal(q.W, got.W, 4);
|
||||
}
|
||||
|
||||
// ── SetCycle / frame advance ────────────────────────────────────────<EFBFBD><EFBFBD>────
|
||||
// ── SetCycle / frame advance ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Advance_NoCycleSet_ReturnsIdentityTransforms()
|
||||
|
|
@ -371,12 +373,12 @@ public sealed class AnimationSequencerTests
|
|||
// Advance a bit to move the frame counter.
|
||||
seq.Advance(0.1f);
|
||||
|
||||
float frameBefore = GetFrameNum(seq);
|
||||
double frameBefore = GetFramePosition(seq);
|
||||
|
||||
// Call SetCycle again with identical args -- fast-path, no reset.
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
float frameAfter = GetFrameNum(seq);
|
||||
double frameAfter = GetFramePosition(seq);
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Expose _frameNum via reflection (test-only).</summary>
|
||||
private static float GetFrameNum(AnimationSequencer seq)
|
||||
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>
|
||||
private static double GetFramePosition(AnimationSequencer seq)
|
||||
{
|
||||
var field = typeof(AnimationSequencer)
|
||||
.GetField("_frameNum",
|
||||
.GetField("_framePosition",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
return field is null ? -1f : (float)field.GetValue(seq)!;
|
||||
return field is null ? -1.0 : (double)field.GetValue(seq)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue