acdream/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs
Erik 1371c2a14c feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode
P0 — research + pins: full CSequence-family verbatim extraction (1756
lines, per-function raw pseudo-C + cleaned flow, decomp line anchors),
ACE cross-reference (9 ranked divergences; headline: retail frame_number
is x87 long double — ACE's float is the worst case, our double the best
available; ACE's frame-boundary epsilon is an ACE fabrication, NOT
retail), current-sequencer map, and the R1 gap map (20 gaps, 13 keeps,
P0-P6 port order). Pinned the one decomp ambiguity (leftover-time carry
after advance_to_next_animation — ACE reading adopted; cdb confirmation
protocol recorded, non-blocking).

P1 — AnimSequenceNode verbatim (gap G1/G2/G16/G18):
- direction-aware BARE-INT boundary pair (get_starting_frame 0x00525c80 /
  get_ending_frame 0x00525cb0): reverse starts at high+1, ends at low —
  NO epsilon;
- multiply_framerate (0x00525be0) swaps low/high on negative factor;
- set_animation_id (0x00525d60) retail clamp order (high<0 -> num-1;
  low>=num -> num-1; high>=num -> num-1; low>high -> high=low);
- ctors with retail defaults (30f/-1/-1; AnimData copy + clamp);
- get_pos_frame null out-of-range (retail; ACE returns identity),
  floor double overload; get_part_frame same discipline;
- NO per-node IsLooping/Velocity/Omega — loop membership is list
  structure, physics accumulators live on the sequence (G16).

22 conformance tests (clamp table, boundary mirror table, swap
round-trip, bounds/floor semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:45:56 +02:00

209 lines
7.8 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.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R1-P1 — verbatim <c>AnimSequenceNode</c> (Phase R plan, gap-map items
/// G1/G2/G16/G18). Oracle: r1-csequence-decomp.md §25-28 (ctors 0x00525d30 /
/// 0x00525f90, set_animation_id 0x00525d60, get_starting_frame 0x00525c80,
/// get_ending_frame 0x00525cb0, multiply_framerate 0x00525be0, get_pos_frame
/// 0x005247b0 / 0x00525c10).
///
/// KEY RETAIL SEMANTICS UNDER TEST:
/// - boundary pair is DIRECTION-AWARE and returns BARE INTS with NO epsilon
/// (ACE's epsilon subtraction is an ACE fabrication — P0-pins.md);
/// - multiply_framerate SWAPS low/high on a negative factor;
/// - set_animation_id clamps in a fixed order (high&lt;0 → num1;
/// low≥num → num1; high≥num → num1; low&gt;high → high=low).
/// </summary>
public class AnimSequenceNodeTests
{
private sealed class FakeLoader : IAnimationLoader
{
private readonly Animation? _anim;
public FakeLoader(Animation? anim) => _anim = anim;
public Animation? LoadAnimation(uint id) => _anim;
}
private static Animation MakeAnim(int numFrames, bool posFrames = false)
{
var anim = new Animation();
for (int f = 0; f < numFrames; f++)
{
var pf = new AnimationFrame(1u);
pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
if (posFrames)
anim.PosFrames.Add(new Frame { Origin = new Vector3(0, f, 0), Orientation = Quaternion.Identity });
}
return anim;
}
[Fact]
public void DefaultCtor_RetailDefaults()
{
// 0x00525d30: framerate=30f, low=-1, high=-1, anim=null.
var n = new AnimSequenceNode();
Assert.Equal(30f, n.Framerate);
Assert.Equal(-1, n.LowFrame);
Assert.Equal(-1, n.HighFrame);
Assert.False(n.HasAnim);
}
[Fact]
public void SetAnimationId_Zero_LeavesAnimNullAndFramesUnclamped()
{
// 0x00525d60: arg2==0 → anim=null; the clamp block is gated on
// anim != null, so low/high stay at their raw values.
var n = new AnimSequenceNode();
n.SetAnimationId(0, new FakeLoader(MakeAnim(10)));
Assert.False(n.HasAnim);
Assert.Equal(-1, n.LowFrame);
Assert.Equal(-1, n.HighFrame);
}
[Theory]
// low, high (pre-clamp), numFrames, expectedLow, expectedHigh
[InlineData(0, -1, 10, 0, 9)] // high<0 → num-1 ("play to end")
[InlineData(12, -1, 10, 9, 9)] // high<0 first, then low>=num → num-1
[InlineData(3, 15, 10, 3, 9)] // high>=num → num-1
[InlineData(15, 15, 10, 9, 9)] // both clamp to num-1
[InlineData(5, 2, 10, 5, 5)] // low>high after clamps → high=low
[InlineData(2, 7, 10, 2, 7)] // in-range untouched
public void SetAnimationId_ClampOrder(int low, int high, int numFrames, int expLow, int expHigh)
{
var n = new AnimSequenceNode { LowFrame = low, HighFrame = high };
n.SetAnimationId(0x0300ABCDu, new FakeLoader(MakeAnim(numFrames)));
Assert.True(n.HasAnim);
Assert.Equal(expLow, n.LowFrame);
Assert.Equal(expHigh, n.HighFrame);
}
[Theory]
// framerate, low, high, expectedStart, expectedEnd — BARE INTS, no epsilon
[InlineData(30f, 2, 7, 2, 8)] // forward: start=low, end=high+1
[InlineData(-30f, 2, 7, 8, 2)] // reverse: start=high+1, end=low
[InlineData(0f, 2, 7, 2, 8)] // zero framerate is NOT < 0 → forward
[InlineData(30f, 0, 0, 0, 1)] // single-frame forward
[InlineData(-30f, 0, 0, 1, 0)] // single-frame reverse
public void BoundaryPair_DirectionAware_BareInts(
float framerate, int low, int high, int expStart, int expEnd)
{
var n = new AnimSequenceNode { Framerate = framerate, LowFrame = low, HighFrame = high };
Assert.Equal(expStart, n.GetStartingFrame());
Assert.Equal(expEnd, n.GetEndingFrame());
}
[Fact]
public void MultiplyFramerate_Positive_NoSwap()
{
var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 };
n.MultiplyFramerate(2f);
Assert.Equal(60f, n.Framerate);
Assert.Equal(2, n.LowFrame);
Assert.Equal(7, n.HighFrame);
}
[Fact]
public void MultiplyFramerate_Negative_SwapsLowHigh()
{
// 0x00525be0: factor < 0 → swap(low_frame, high_frame), then
// framerate *= factor. Coupled with the direction-aware boundary
// pair (framerate now negative reads the swapped fields).
var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 };
n.MultiplyFramerate(-1f);
Assert.Equal(-30f, n.Framerate);
Assert.Equal(7, n.LowFrame);
Assert.Equal(2, n.HighFrame);
// Reverse playback boundaries against the SWAPPED fields:
// start = high_frame(2)+1 = 3?? — no: framerate<0 → start = high+1
// where high_frame is now 2 → 3; end = low_frame = 7.
Assert.Equal(3, n.GetStartingFrame());
Assert.Equal(7, n.GetEndingFrame());
}
[Fact]
public void MultiplyFramerate_DoubleNegative_RoundTrips()
{
var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 };
n.MultiplyFramerate(-1f);
n.MultiplyFramerate(-1f);
Assert.Equal(30f, n.Framerate);
Assert.Equal(2, n.LowFrame);
Assert.Equal(7, n.HighFrame);
}
[Fact]
public void GetPosFrame_NullAnim_ReturnsNull()
{
var n = new AnimSequenceNode();
Assert.Null(n.GetPosFrame(0));
}
[Fact]
public void GetPosFrame_OutOfRange_ReturnsNull()
{
// 0x00525c10: retail returns NULL out of range (ACE returns identity
// — an ACE-ism, gap G18: port the null).
var n = new AnimSequenceNode();
n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true)));
Assert.Null(n.GetPosFrame(-1));
Assert.Null(n.GetPosFrame(3));
Assert.NotNull(n.GetPosFrame(2));
}
[Fact]
public void GetPosFrame_DoubleOverload_Floors()
{
// 0x005247b0: floor(double) → int overload.
var n = new AnimSequenceNode();
n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true)));
var f = n.GetPosFrame(1.99);
Assert.NotNull(f);
Assert.Equal(1f, f!.Origin.Y); // frame index 1, not 2
}
[Fact]
public void GetPosFrame_AnimWithoutPosFrames_ReturnsNull()
{
var n = new AnimSequenceNode();
n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: false)));
Assert.Null(n.GetPosFrame(0));
}
[Fact]
public void GetPartFrame_BoundsAndValue()
{
var n = new AnimSequenceNode();
n.SetAnimationId(1, new FakeLoader(MakeAnim(3)));
Assert.Null(n.GetPartFrame(-1));
Assert.Null(n.GetPartFrame(3));
var pf = n.GetPartFrame(2);
Assert.NotNull(pf);
Assert.Equal(2f, pf!.Frames[0].Origin.X);
}
[Fact]
public void CtorFromAnimData_CopiesThenClamps()
{
// 0x00525f90: copies framerate/low/high from AnimData then runs
// set_animation_id (which clamps against the resolved anim).
QualifiedDataId<Animation> qid = 0x03001234u;
var ad = new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1,
Framerate = 15f,
};
var n = new AnimSequenceNode(ad, new FakeLoader(MakeAnim(5)));
Assert.Equal(15f, n.Framerate);
Assert.Equal(0, n.LowFrame);
Assert.Equal(4, n.HighFrame); // -1 sentinel clamped to num-1
Assert.True(n.HasAnim);
}
}