feat(R1-P2): verbatim CSequence container + list surgery

CSequence (Core/Physics/Motion): anim-node list with retail's exact
cursor semantics —
- append_animation (0x00525510): first_cyclic slides to the JUST-
  APPENDED node on EVERY call (the cyclic tail is always the last
  appended node); curr_anim seeds to head + get_starting_frame only
  when null; unresolvable anims discarded (G10);
- remove_cyclic_anims (0x00524e40): removed curr_anim snaps BACK to
  prev at get_ending_frame (or 0.0); first_cyclic = new tail;
- remove_link_animations/remove_all_link_animations (0x00524be0/
  0x00524ca0): removed curr_anim snaps FORWARD to first_cyclic at
  get_starting_frame (G11);
- apricot (0x00524b40, PDB-verified retail name): consumed-head trim
  bounded by curr_anim AND first_cyclic;
- clear (0x005255b0) resets placement fields too — raw body is
  authority over the gap map's G20 note;
- sequence-level velocity/omega with set/combine/subtract (G12);
- multiply_cyclic_animation_fr touches framerates ONLY (G13 —
  velocity rescale belongs to R2's change_cycle_speed composite);
- placement frame family + floored accessors (G14).

Register: AD-33 (double vs x87 long double frame_number, G15),
AD-34 (managed LinkedList vs intrusive DLList).

17 list-surgery state-table tests; 39 total R1 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 19:50:19 +02:00
parent 1371c2a14c
commit 778744bf3e
3 changed files with 592 additions and 1 deletions

View file

@ -0,0 +1,323 @@
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-P2 — verbatim <c>CSequence</c> container + list surgery (gap-map
/// G10/G11/G12/G14/G15/G20). Oracle: r1-csequence-decomp.md §1-§17, §20,
/// §24 (ctor 0x005249f0, clear 0x005255b0, clear_animations 0x00524dc0,
/// clear_physics 0x00524d50, remove_cyclic_anims 0x00524e40,
/// remove_link_animations 0x00524be0, remove_all_link_animations
/// 0x00524ca0, apricot 0x00524b40, append_animation 0x00525510,
/// set/combine/subtract physics 0x00524880-0x00524900,
/// multiply_cyclic_animation_fr 0x00524940, placement family
/// 0x00524970-0x005249d0).
///
/// KEY RETAIL SEMANTICS UNDER TEST:
/// - append_animation slides first_cyclic to the JUST-APPENDED node on
/// EVERY call (G10) and seeds curr_anim=head + frame_number=starting
/// only when curr_anim was null;
/// - remove_cyclic_anims snaps a removed curr_anim BACK to the previous
/// node at its get_ending_frame() (or 0.0 when none);
/// - remove_link_animations snaps a removed curr_anim FORWARD to
/// first_cyclic at its get_starting_frame();
/// - apricot trims consumed head nodes bounded by curr_anim AND
/// first_cyclic;
/// - clear (0x005255b0 raw body) resets placement_frame/id too — the
/// "2-instruction clear" note in the gap map was wrong, the raw body
/// is authority;
/// - physics accumulators live on the SEQUENCE (replace/combine/subtract);
/// - multiply_cyclic_animation_fr touches node framerates ONLY (G13).
/// </summary>
public class CSequenceTests
{
private sealed class MapLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _map = new();
public void Add(uint id, Animation anim) => _map[id] = anim;
public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null;
}
private static Animation MakeAnim(int numFrames)
{
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);
}
return anim;
}
private static AnimData Ad(uint animId, int low = 0, int high = -1, float framerate = 30f)
{
QualifiedDataId<Animation> qid = animId;
return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate };
}
private static (CSequence seq, MapLoader loader) NewSeq(params (uint id, int frames)[] anims)
{
var loader = new MapLoader();
foreach (var (id, frames) in anims)
loader.Add(id, MakeAnim(frames));
return (new CSequence(loader), loader);
}
// ── append_animation (G10) ──────────────────────────────────────────
[Fact]
public void Append_First_SeedsCurrAnimAndFrameNumber()
{
var (seq, _) = NewSeq((1u, 10));
seq.AppendAnimation(Ad(1u, low: 3));
Assert.Equal(1, seq.Count);
Assert.NotNull(seq.CurrAnim);
Assert.Same(seq.CurrAnim, seq.FirstCyclic);
Assert.Equal(3.0, seq.FrameNumber); // head.get_starting_frame()
}
[Fact]
public void Append_SlidesFirstCyclicToNewTail_EveryCall()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4));
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
seq.AppendAnimation(Ad(3u));
Assert.Equal(3, seq.Count);
Assert.Equal(4 - 1, seq.FirstCyclic!.HighFrame); // the LAST appended (anim 3, 4 frames)
Assert.Equal(10 - 1, seq.CurrAnim!.HighFrame); // curr stays at head (anim 1)
Assert.Equal(0.0, seq.FrameNumber);
}
[Fact]
public void Append_UnresolvableAnim_Discarded()
{
var (seq, _) = NewSeq((1u, 10));
seq.AppendAnimation(Ad(999u)); // not in loader
Assert.Equal(0, seq.Count);
Assert.Null(seq.CurrAnim);
Assert.False(seq.HasAnims());
}
// ── remove_cyclic_anims (G11) ───────────────────────────────────────
[Fact]
public void RemoveCyclicAnims_CurrOutsideTail_KeepsCurr_FirstCyclicToNewTail()
{
// A (link) + B (cycle): first_cyclic == B, curr == A (head).
var (seq, _) = NewSeq((1u, 10), (2u, 5));
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
seq.RemoveCyclicAnims();
Assert.Equal(1, seq.Count); // B deleted
Assert.Equal(9, seq.CurrAnim!.HighFrame); // still A
Assert.Same(seq.CurrAnim, seq.FirstCyclic); // first_cyclic = new tail (A)
}
[Fact]
public void RemoveCyclicAnims_CurrInsideTail_SnapsBackToPrevAtEndingFrame()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5));
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
seq.SetCurrAnimForTest(1); // curr = B (index 1, inside cyclic tail)
seq.RemoveCyclicAnims();
Assert.Equal(1, seq.Count);
Assert.Equal(9, seq.CurrAnim!.HighFrame); // snapped back to A
Assert.Equal(10.0, seq.FrameNumber); // A.get_ending_frame() = high+1 = 10
}
[Fact]
public void RemoveCyclicAnims_SingleNode_EmptiesAndZeroes()
{
var (seq, _) = NewSeq((1u, 10));
seq.AppendAnimation(Ad(1u));
seq.RemoveCyclicAnims();
Assert.Equal(0, seq.Count);
Assert.Null(seq.CurrAnim);
Assert.Null(seq.FirstCyclic);
Assert.Equal(0.0, seq.FrameNumber);
}
// ── remove_link_animations / remove_all_link_animations (G11) ──────
[Fact]
public void RemoveLinkAnimations_RemovesPredecessorsOfFirstCyclic()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4));
seq.AppendAnimation(Ad(1u)); // A
seq.AppendAnimation(Ad(2u)); // B
seq.AppendAnimation(Ad(3u)); // C = first_cyclic
seq.RemoveLinkAnimations(1); // removes B (immediate predecessor)
Assert.Equal(2, seq.Count);
Assert.Equal(9, seq.CurrAnim!.HighFrame); // A untouched (curr was A)
Assert.Equal(3, seq.FirstCyclic!.HighFrame); // C untouched
}
[Fact]
public void RemoveLinkAnimations_CurrRemoved_SnapsForwardToFirstCyclicStart()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5));
seq.AppendAnimation(Ad(1u)); // A (curr)
seq.AppendAnimation(Ad(2u)); // B = first_cyclic
seq.RemoveLinkAnimations(1); // removes A == curr
Assert.Equal(1, seq.Count);
Assert.Same(seq.FirstCyclic, seq.CurrAnim);
Assert.Equal(0.0, seq.FrameNumber); // B.get_starting_frame() = low = 0
}
[Fact]
public void RemoveAllLinkAnimations_RemovesEverythingBeforeFirstCyclic()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4));
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
seq.AppendAnimation(Ad(3u)); // first_cyclic
seq.RemoveAllLinkAnimations();
Assert.Equal(1, seq.Count);
Assert.Same(seq.FirstCyclic, seq.CurrAnim);
Assert.Equal(3, seq.CurrAnim!.HighFrame);
}
// ── apricot (G11/G19) ───────────────────────────────────────────────
[Fact]
public void Apricot_HeadIsCurr_NoOp()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5));
seq.AppendAnimation(Ad(1u));
seq.AppendAnimation(Ad(2u));
seq.Apricot();
Assert.Equal(2, seq.Count);
}
[Fact]
public void Apricot_TrimsConsumedHeads_StopsAtCurr()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4));
seq.AppendAnimation(Ad(1u)); // A (consumed)
seq.AppendAnimation(Ad(2u)); // B (curr)
seq.AppendAnimation(Ad(3u)); // C = first_cyclic
seq.SetCurrAnimForTest(1); // curr = B
seq.Apricot();
Assert.Equal(2, seq.Count); // A trimmed
Assert.Equal(4, seq.CurrAnim!.HighFrame); // B still curr
}
[Fact]
public void Apricot_BoundedByFirstCyclic_EvenIfCurrBeyond()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4));
seq.AppendAnimation(Ad(1u)); // A
seq.AppendAnimation(Ad(2u)); // B
seq.AppendAnimation(Ad(3u)); // C = first_cyclic = curr target
seq.SetCurrAnimForTest(2); // curr = C
seq.Apricot();
Assert.Equal(1, seq.Count); // A and B trimmed; stops at first_cyclic
Assert.Equal(3, seq.CurrAnim!.HighFrame);
}
// ── physics accumulators (G12) ──────────────────────────────────────
[Fact]
public void Physics_SetCombineSubtract()
{
var (seq, _) = NewSeq();
seq.SetVelocity(new Vector3(1, 2, 3));
seq.SetOmega(new Vector3(0, 0, 1));
seq.CombinePhysics(new Vector3(1, 0, 0), new Vector3(0, 0, 0.5f));
Assert.Equal(new Vector3(2, 2, 3), seq.Velocity);
Assert.Equal(new Vector3(0, 0, 1.5f), seq.Omega);
seq.SubtractPhysics(new Vector3(2, 2, 3), new Vector3(0, 0, 1.5f));
Assert.Equal(Vector3.Zero, seq.Velocity);
Assert.Equal(Vector3.Zero, seq.Omega);
}
// ── multiply_cyclic_animation_fr (G13) ──────────────────────────────
[Fact]
public void MultiplyCyclicFramerate_TouchesOnlyCyclicTailFramerates()
{
var (seq, _) = NewSeq((1u, 10), (2u, 5));
seq.AppendAnimation(Ad(1u, framerate: 30f)); // A (link, pre-cyclic after next append)
seq.AppendAnimation(Ad(2u, framerate: 30f)); // B = first_cyclic
seq.MultiplyCyclicAnimationFramerate(2f);
Assert.Equal(30f, seq.CurrAnim!.Framerate); // A untouched
Assert.Equal(60f, seq.FirstCyclic!.Framerate); // B scaled
// Velocity/Omega untouched (retail scales those via
// subtract/combine_motion in R2, never here).
Assert.Equal(Vector3.Zero, seq.Velocity);
}
// ── clear family (G20 corrected) ────────────────────────────────────
[Fact]
public void Clear_WipesAnimsPhysicsAndPlacement()
{
var (seq, _) = NewSeq((1u, 10));
seq.AppendAnimation(Ad(1u));
seq.SetVelocity(new Vector3(1, 1, 1));
var pf = new AnimationFrame(1u);
seq.SetPlacementFrame(pf, 0x65u);
seq.Clear();
Assert.Equal(0, seq.Count);
Assert.Null(seq.CurrAnim);
Assert.Equal(0.0, seq.FrameNumber);
Assert.Equal(Vector3.Zero, seq.Velocity);
Assert.Null(seq.PlacementFrame); // raw 0x005255b0 resets placement too
Assert.Equal(0u, seq.PlacementFrameId);
}
// ── placement + accessors (G14) ─────────────────────────────────────
[Fact]
public void GetCurrAnimframe_PlacementFallback_WhenNoCurrAnim()
{
var (seq, _) = NewSeq();
var pf = new AnimationFrame(1u);
seq.SetPlacementFrame(pf, 0x65u);
Assert.Same(pf, seq.GetCurrAnimframe());
}
[Fact]
public void GetCurrAnimframe_FlooredFrameLookup()
{
var (seq, _) = NewSeq((1u, 10));
seq.AppendAnimation(Ad(1u));
seq.FrameNumber = 2.9;
var frame = seq.GetCurrAnimframe();
Assert.NotNull(frame);
Assert.Equal(2f, frame!.Frames[0].Origin.X); // frame index 2
Assert.Equal(2, seq.GetCurrFrameNumber());
}
}