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>
This commit is contained in:
Erik 2026-07-02 19:45:56 +02:00
parent cae56afc82
commit 1371c2a14c
7 changed files with 3363 additions and 0 deletions

View file

@ -0,0 +1,209 @@
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);
}
}