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,159 @@
using System;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R1-P1 — verbatim port of retail's <c>AnimSequenceNode</c> (Phase R plan
/// `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`, stage R1;
/// oracle `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md`
/// §25-28).
///
/// One node of a <c>CSequence</c>'s animation list: a resolved dat
/// <see cref="Animation"/> plus a playable frame window
/// (<see cref="LowFrame"/>..<see cref="HighFrame"/>) and a signed
/// <see cref="Framerate"/> whose SIGN is retail's playback-direction flag.
///
/// Retail semantics preserved exactly (gap-map items G1/G2/G16/G18):
/// <list type="bullet">
/// <item>The boundary pair (<see cref="GetStartingFrame"/> /
/// <see cref="GetEndingFrame"/>) is direction-aware and returns BARE
/// integers — retail has NO epsilon here (0x00525c80/0x00525cb0). ACE's
/// <c>PhysicsGlobals.EPSILON</c> subtraction compensates for ACE's own
/// <c>float FrameNumber</c> and must not be copied (P0-pins.md).</item>
/// <item><see cref="MultiplyFramerate"/> SWAPS <see cref="LowFrame"/> and
/// <see cref="HighFrame"/> when the factor is negative (0x00525be0) —
/// coupled with the boundary pair's <c>framerate &lt; 0</c> test.</item>
/// <item><see cref="SetAnimationId"/> clamps in retail's exact order
/// (0x00525d60): high&lt;0 → num1; low≥num → num1; high≥num → num1;
/// low&gt;high → high=low.</item>
/// <item><see cref="GetPosFrame(int)"/> returns null out of range
/// (retail; ACE's identity-frame return is an ACE-ism).</item>
/// </list>
///
/// Unlike the legacy <c>AnimationSequencer.AnimNode</c>, retail nodes carry
/// NO per-node IsLooping/Velocity/Omega — loop membership is list structure
/// (<c>first_cyclic</c>) and physics accumulators live on the sequence
/// (G16).
/// </summary>
public sealed class AnimSequenceNode
{
/// <summary>Resolved dat animation, or null (id 0 / missing).</summary>
public Animation? Anim { get; private set; }
/// <summary>
/// Frames per second; NEGATIVE means reverse playback (retail's
/// direction flag). Default 30f (0x00525d30).
/// </summary>
public float Framerate = 30f;
/// <summary>Inclusive window low bound. Default 1 (0x00525d30).</summary>
public int LowFrame = -1;
/// <summary>Inclusive window high bound; 1 = "to the end" sentinel
/// resolved by <see cref="SetAnimationId"/>. Default 1.</summary>
public int HighFrame = -1;
public bool HasAnim => Anim is not null;
/// <summary>Default ctor — retail defaults (0x00525d30).</summary>
public AnimSequenceNode()
{
}
/// <summary>
/// Ctor from a MotionData <see cref="AnimData"/> entry (0x00525f90):
/// copy framerate/low/high, then resolve + clamp via
/// <see cref="SetAnimationId"/>.
/// </summary>
public AnimSequenceNode(AnimData animData, IAnimationLoader loader)
{
Framerate = animData.Framerate;
LowFrame = animData.LowFrame;
HighFrame = animData.HighFrame;
SetAnimationId((uint)animData.AnimId, loader);
}
/// <summary>
/// Resolve the dat animation and clamp the frame window
/// (0x00525d60). The clamp block runs only when an animation resolved;
/// order is retail-exact.
/// </summary>
public void SetAnimationId(uint animId, IAnimationLoader loader)
{
Anim = animId == 0 ? null : loader.LoadAnimation(animId);
if (Anim is null)
return;
int numFrames = Anim.PartFrames.Count;
if (HighFrame < 0)
HighFrame = numFrames - 1;
if (LowFrame >= numFrames)
LowFrame = numFrames - 1;
if (HighFrame >= numFrames)
HighFrame = numFrames - 1;
if (LowFrame > HighFrame)
HighFrame = LowFrame;
}
/// <summary>
/// Direction-aware starting boundary (0x00525c80): reverse playback
/// starts at <c>high_frame + 1</c>, forward at <c>low_frame</c>.
/// BARE int — no epsilon (G1).
/// </summary>
public int GetStartingFrame() => Framerate < 0f ? HighFrame + 1 : LowFrame;
/// <summary>
/// Direction-aware ending boundary (0x00525cb0): reverse ends at
/// <c>low_frame</c>, forward at <c>high_frame + 1</c>. BARE int.
/// </summary>
public int GetEndingFrame() => Framerate < 0f ? LowFrame : HighFrame + 1;
/// <summary>
/// Scale playback rate (0x00525be0): a NEGATIVE factor swaps
/// low/high before multiplying — the swapped fields plus the
/// now-negative framerate are how retail encodes reversed windows.
/// </summary>
public void MultiplyFramerate(float factor)
{
if (factor < 0f)
{
(LowFrame, HighFrame) = (HighFrame, LowFrame);
}
Framerate *= factor;
}
/// <summary>
/// Root-motion frame at a double position (0x005247b0): floor then the
/// int overload.
/// </summary>
public Frame? GetPosFrame(double frameNumber)
=> GetPosFrame((int)Math.Floor(frameNumber));
/// <summary>
/// Root-motion frame by index (0x00525c10): null when no animation,
/// index out of range, or the animation carries no PosFrames.
/// </summary>
public Frame? GetPosFrame(int index)
{
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
return null;
if (Anim.PosFrames is null || index >= Anim.PosFrames.Count)
return null;
return Anim.PosFrames[index];
}
/// <summary>
/// Skeletal part frame by index — same bounds discipline as
/// <see cref="GetPosFrame(int)"/>.
/// </summary>
public AnimationFrame? GetPartFrame(int index)
{
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
return null;
return Anim.PartFrames[index];
}
}