diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0a3abfb4..5ad67d46 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -61,7 +61,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 32 rows +## 2. Adaptation (AD) — 34 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -96,6 +96,8 @@ accepted-divergence entries (#96, #49, #50). | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | | AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | | AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) | +| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) | +| AD-34 | Retail's intrusive `DLListBase`/`DLListData` anim-node list is a managed `LinkedList`; node identity via `LinkedListNode<>` references (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`) | Same topology + cursor semantics (curr_anim/first_cyclic are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0 | --- diff --git a/src/AcDream.Core/Physics/Motion/CSequence.cs b/src/AcDream.Core/Physics/Motion/CSequence.cs new file mode 100644 index 00000000..4a41c2de --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/CSequence.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R1-P2 — verbatim port of retail's CSequence container + list +/// surgery (Phase R plan stage R1; oracle +/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17, +/// §20, §24). The per-tick advance (`update`/`update_internal`/ +/// `apply_physics`/hook dispatch) lands in R1-P3/P4. +/// +/// Structure: a doubly-linked animation-node list with two cursors — +/// (the node currently playing) and +/// (where the looping tail begins; everything +/// before it is one-shot "link" animation). Retail invariant (G10): +/// append_animation slides first_cyclic to the JUST-APPENDED +/// node on EVERY call — the cyclic tail is always exactly the last node +/// appended so far. +/// +/// Physics accumulators (/) live on +/// the SEQUENCE, not per node (G16); retail replaces them via +/// set_velocity/set_omega and algebraically blends via +/// combine_physics/subtract_physics (the R2 fast path's mechanism). +/// +/// Divergence register (rows added with this commit): +/// +/// frame_number is x87 long double in retail +/// (acclient.h:30747); C# double is the closest available (G15). +/// The intrusive DLList is a managed ; +/// node identity semantics preserved via +/// references. +/// +/// +public sealed class CSequence +{ + private readonly LinkedList _animList = new(); // anim_list (DLList) + private LinkedListNode? _firstCyclic; // first_cyclic + private LinkedListNode? _currAnim; // curr_anim + private readonly IAnimationLoader _loader; + + /// Fractional frame position within . + /// Retail x87 long double → double (register row, G15). + public double FrameNumber; + + /// Sequence root-motion velocity accumulator (body-local). + public Vector3 Velocity; + + /// Sequence angular-velocity accumulator. + public Vector3 Omega; + + /// Static pose used when no animation node is active + /// (placement_frame, §16). + public AnimationFrame? PlacementFrame { get; private set; } + + public uint PlacementFrameId { get; private set; } + + public CSequence(IAnimationLoader loader) => _loader = loader; + + // ── inspection surface (adapter + tests) ──────────────────────────── + + public AnimSequenceNode? CurrAnim => _currAnim?.Value; + public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value; + public int Count => _animList.Count; + + /// has_anims (0x00524bd0). + public bool HasAnims() => _animList.Count > 0; + + /// TEST SEAM: reposition curr_anim by list index (retail state + /// reached via update_internal, which lands in P4). + public void SetCurrAnimForTest(int index) + { + var n = _animList.First; + for (int i = 0; i < index && n != null; i++) n = n.Next; + _currAnim = n; + } + + // ── append_animation (0x00525510, §24) ────────────────────────────── + + /// + /// Append a MotionData anim entry. A node whose dat animation fails to + /// resolve is discarded. first_cyclic slides to the appended + /// node on EVERY call; curr_anim seeds to the head (with + /// frame_number = get_starting_frame()) only when it was null. + /// + public void AppendAnimation(AnimData animData) + { + var node = new AnimSequenceNode(animData, _loader); + if (!node.HasAnim) + return; // retail deletes the node — discard + + _animList.AddLast(node); + _firstCyclic = _animList.Last; + + if (_currAnim is null) + { + _currAnim = _animList.First; + FrameNumber = _currAnim!.Value.GetStartingFrame(); + } + } + + // ── clear family (§3-§5) ──────────────────────────────────────────── + + /// clear (0x005255b0): full wipe INCLUDING the placement + /// fields — the raw body resets them (the "2-instruction clear" note in + /// the gap map was wrong; raw decomp is authority). + public void Clear() + { + ClearAnimations(); + ClearPhysics(); + PlacementFrame = null; + PlacementFrameId = 0; + } + + /// clear_animations (0x00524dc0): delete every node, + /// null both cursors, zero frame_number. + public void ClearAnimations() + { + _animList.Clear(); + _firstCyclic = null; + _currAnim = null; + FrameNumber = 0.0; + } + + /// clear_physics (0x00524d50). + public void ClearPhysics() + { + Velocity = Vector3.Zero; + Omega = Vector3.Zero; + } + + // ── remove family (§6-§8) ─────────────────────────────────────────── + + /// + /// remove_cyclic_anims (0x00524e40): delete first_cyclic + /// → tail. A removed curr_anim snaps BACK to the previous node + /// with frame_number = prev.get_ending_frame() (or 0.0 when the + /// list emptied). Afterwards first_cyclic = new tail (or null). + /// + public void RemoveCyclicAnims() + { + var node = _firstCyclic; + while (node is not null) + { + var next = node.Next; + if (ReferenceEquals(_currAnim, node)) + { + var prev = node.Previous; + _currAnim = prev; + FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame(); + } + _animList.Remove(node); + node = next; + } + _firstCyclic = _animList.Last; + } + + /// + /// remove_link_animations(count) (0x00524be0): delete up to + /// predecessors of first_cyclic. A + /// removed curr_anim snaps FORWARD to first_cyclic with + /// frame_number = get_starting_frame(). + /// + public void RemoveLinkAnimations(int count) + { + for (int i = 0; i < count; i++) + { + var prev = _firstCyclic?.Previous; + if (prev is null) + break; + + if (ReferenceEquals(_currAnim, prev)) + { + _currAnim = _firstCyclic; + if (_firstCyclic is not null) + FrameNumber = _firstCyclic.Value.GetStartingFrame(); + } + _animList.Remove(prev); + } + } + + /// remove_all_link_animations (0x00524ca0): loop until + /// first_cyclic has no predecessor. + public void RemoveAllLinkAnimations() + { + while (_firstCyclic?.Previous is not null) + RemoveLinkAnimations(1); + } + + /// + /// apricot (0x00524b40; the PDB-verified retail name): trim + /// consumed nodes from the head, bounded by BOTH curr_anim + /// (stop — still live) and first_cyclic (defensive bound — + /// never delete into the cyclic tail). Called after every update (§22). + /// + public void Apricot() + { + var head = _animList.First; + if (head is null || ReferenceEquals(head, _currAnim)) + return; + + while (!ReferenceEquals(head, _firstCyclic)) + { + _animList.Remove(head!); + head = _animList.First; + if (head is null || ReferenceEquals(head, _currAnim)) + break; + } + } + + // ── physics accumulators (§10-§13) ────────────────────────────────── + + public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880 + public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0 + public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0 + public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900 + + // ── multiply_cyclic_animation_fr (0x00524940, §14) ────────────────── + + /// + /// Scale the framerate of every node from first_cyclic to the + /// tail. Framerates ONLY (G13) — retail rescales the sequence + /// velocity/omega separately via change_cycle_speed's + /// subtract_motion/combine_motion composite (R2). + /// + public void MultiplyCyclicAnimationFramerate(float factor) + { + for (var n = _firstCyclic; n is not null; n = n.Next) + n.Value.MultiplyFramerate(factor); + } + + // ── placement + accessors (§15-§17) ───────────────────────────────── + + /// set_placement_frame (0x005249b0). + public void SetPlacementFrame(AnimationFrame? frame, uint id) + { + PlacementFrame = frame; + PlacementFrameId = id; + } + + /// get_curr_animframe (0x00524970): the floored current + /// part frame, or the placement frame when no node is active. + public AnimationFrame? GetCurrAnimframe() + { + if (_currAnim is null) + return PlacementFrame; + return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber)); + } + + /// get_curr_frame_number (0x005249d0). + public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber); + + // ── internal cursors for P4 (update_internal operates on nodes) ───── + + internal LinkedListNode? CurrAnimNode + { + get => _currAnim; + set => _currAnim = value; + } + + internal LinkedListNode? FirstCyclicNode => _firstCyclic; + internal LinkedList AnimList => _animList; +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs new file mode 100644 index 00000000..62f66e60 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs @@ -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; + +/// +/// R1-P2 — verbatim CSequence 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). +/// +public class CSequenceTests +{ + private sealed class MapLoader : IAnimationLoader + { + private readonly Dictionary _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 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()); + } +}