using System.Linq; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; namespace AcDream.Core.Tests.Physics.Motion; // ───────────────────────────────────────────────────────────────────────────── // CMotionTableTests — R2-Q2 conformance harness for the verbatim // CMotionTable::GetObjectSequence dispatcher (r2-port-plan.md §3 Q2). // // Every test is labeled with the gap id(s) it pins per the plan's REQUIRED // case list. Fixture convention matches AnimationSequencerTests.cs's // Fixtures class (file-local, replicated here per the task's instruction to // keep this file self-contained). // // Command-word constants below are real DatReaderWriter.Enums.MotionCommand // values (verified via reflection against Chorizite.DatReaderWriter 2.1.7, // 2026-07-02) — NOT synthetic bit patterns — so the class-bit dispatch // (cycle/modifier/action/style) exercises the same bit layout retail ships: // Ready = 0x41000003 (cycle) // WalkForward = 0x45000005 (cycle) // RunForward = 0x44000007 (cycle) // TurnRight = 0x6500000D (cycle AND modifier — both bits set; // the AP-73 run-while-turning mechanism test // depends on this real dual-class shape) // Jump = 0x2500003B (modifier only) // ThrustMed = 0x10000058 (action only) // HandCombat = 0x8000003C (style; top bit set) // NonCombat = 0x8000003D (style) // ───────────────────────────────────────────────────────────────────────────── file static class Fixtures { public static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) { var anim = new Animation(); for (int f = 0; f < numFrames; f++) { var pf = new AnimationFrame((uint)numParts); for (int p = 0; p < numParts; p++) pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); anim.PartFrames.Add(pf); } return anim; } public static AnimData MakeAnimData(uint animId, float framerate) => new() { AnimId = (QualifiedDataId)animId, LowFrame = 0, HighFrame = -1, Framerate = framerate, }; public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0, Vector3? velocity = null, Vector3? omega = null) { var md = new MotionData { Bitfield = bitfield }; md.Anims.Add(MakeAnimData(animId, framerate)); if (velocity is { } v) { md.Velocity = v; md.Flags |= MotionDataFlags.HasVelocity; } if (omega is { } w) { md.Omega = w; md.Flags |= MotionDataFlags.HasOmega; } return md; } /// MotionData with N AnimData entries (for outTicks sum tests, A3). public static MotionData MakeMultiAnimMotionData(uint firstAnimId, int count, float framerate) { var md = new MotionData(); for (int i = 0; i < count; i++) md.Anims.Add(MakeAnimData(firstAnimId + (uint)i, framerate)); return md; } public static void AddLink(MotionTable mt, uint style, uint fromSubstate, uint toSubstate, MotionData data) { int outerKey = (int)((style << 16) | (fromSubstate & 0xFFFFFFu)); if (!mt.Links.TryGetValue(outerKey, out var cmd)) { cmd = new MotionCommandData(); mt.Links[outerKey] = cmd; } cmd.MotionData[(int)toSubstate] = data; } public static void AddCycle(MotionTable mt, uint style, uint substate, MotionData data) { int key = (int)((style << 16) | (substate & 0xFFFFFFu)); mt.Cycles[key] = data; } public static void AddModifier(MotionTable mt, uint style, uint modifier, MotionData data, bool styleSpecific = true) { int key = styleSpecific ? (int)((style << 16) | (modifier & 0xFFFFFFu)) : (int)(modifier & 0xFFFFFFu); mt.Modifiers[key] = data; } } public sealed class CMotionTableTests { // Real retail command words (DatReaderWriter.Enums.MotionCommand). private const uint Ready = 0x41000003u; private const uint WalkForward = 0x45000005u; private const uint RunForward = 0x44000007u; private const uint TurnRight = 0x6500000Du; // cycle AND modifier class private const uint Jump = 0x2500003Bu; // modifier only private const uint ThrustMed = 0x10000058u; // action only private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set private const uint NonCombatStyle = 0x8000003Du; // style, top bit set // ── Ready→Walk link+cycle chain shape (H1, base dispatcher) ──────── [Fact] public void GetObjectSequence_ReadyToWalk_BuildsLinkThenCycleChain() { var loader = new FakeLoader(); uint readyAnim = 0x03000001u, walkAnim = 0x03000002u, linkAnim = 0x03000003u; loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var readyCycle = Fixtures.MakeMotionData(readyAnim, 30f); Fixtures.AddCycle(mt, NonCombatStyle, Ready, readyCycle); var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f); Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle); var link = Fixtures.MakeMotionData(linkAnim, 30f); Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(loader); bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(WalkForward, state.Substate); Assert.Equal(NonCombatStyle, state.Style); Assert.Equal(1f, state.SubstateMod); // list shape: link then cycle (2 nodes total). Assert.Equal(2, seq.Count); // A3: outTicks = link.num_anims + cycle.num_anims - 1 = 1 + 1 - 1 = 1 Assert.Equal(1u, outTicks); } [Fact] public void GetObjectSequence_ReadyToWalk_FirstCyclicIsTheCycleNotTheLink() { var loader = new FakeLoader(); uint linkAnim = 0x03000010u, cycleAnim = 0x03000011u; loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(0x03000012u, 30f)); Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(cycleAnim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 1f, out _, stopCall: false)); Assert.NotNull(seq.FirstCyclic); Assert.NotNull(seq.CurrAnim); // The cyclic tail is the LAST appended node (append_animation slides // first_cyclic on every call, R1 G10) — that's the cycle, not the link. Assert.Equal(2, seq.Count); } // ── walk↔run same-substate re-speed fast path (H8) ───────────────── [Fact] public void GetObjectSequence_SameSubstateSameSignFasterSpeed_TakesReSpeedFastPath_NoListChange() { var loader = new FakeLoader(); uint walkAnim = 0x03000020u; loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f, velocity: new Vector3(0, 3.12f, 0)); Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; var seq = new CSequence(loader); // Seed the sequence as if WalkForward is already playing at speed 1.0. Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); int countBefore = seq.Count; var firstCyclicBefore = seq.FirstCyclic; var velBefore = seq.Velocity; // Re-issue WalkForward at speed 2.0 (same substate, same sign). bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 2f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(2f, state.SubstateMod); // NO list change — fast path never calls remove_cyclic_anims/add_motion chain. Assert.Equal(countBefore, seq.Count); Assert.Same(firstCyclicBefore, seq.FirstCyclic); // velocity = subtract-old(1x) + combine-new(2x) => net velocity scales to 2x the base. Assert.Equal(velBefore * 2f, seq.Velocity); // outTicks stays 0 on the fast path (retail: *arg6=0 at entry, never reassigned // before the early `return 1`). Assert.Equal(0u, outTicks); } [Fact] public void GetObjectSequence_SameSubstateSameSignFastPath_RescalesFramerates() { var loader = new FakeLoader(); uint walkAnim = 0x03000021u; loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 10f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); double frBefore = seq.CurrAnim!.Framerate; Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 2.5f, out _, stopCall: false)); double frAfter = seq.CurrAnim!.Framerate; Assert.Equal(frBefore * 2.5, frAfter, 3); } // ── sign-flip Walk(+)→Walk(-) routes the style-default double-hop (A2) ── [Fact] public void GetObjectSequence_SignFlipSameSubstate_RoutesStyleDefaultDoubleHop() { // Walk(+1.0) -> Walk(-1.0): same substate id, OPPOSITE sign. This must // NOT take the re-speed fast path (same_sign gate fails) and must NOT // take the direct-link path (no link registered substate->substate); // it routes via the style-default double-hop: // hop1: get_link(style, substate, oldSpeed, styleDefaultSubstate, 1f) // hop2: get_link(style, styleDefaultSubstate, 1f, substate, newSpeed) var loader = new FakeLoader(); uint walkAnim = 0x03000030u, hop1Anim = 0x03000031u, hop2Anim = 0x03000032u; loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(hop1Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(hop2Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f)); // hop1: WalkForward -> Ready (style default), hop2: Ready -> WalkForward Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(hop1Anim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(hop2Anim, 30f)); var cmt = new CMotionTable(mt); // Seed state DIRECTLY (bypassing GetObjectSequence) so state.Substate // starts at WalkForward WITHOUT the seeding call itself tripping the // WalkForward->WalkForward double-hop routing this fixture's own // links would otherwise trigger (both hop links target/originate at // WalkForward, which would also satisfy a same-substate rebuild). var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; var seq = new CSequence(loader); CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(walkAnim, 30f), 1f); Assert.Equal(1, seq.Count); bool ok = cmt.GetObjectSequence(WalkForward, state, seq, -1f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(-1f, state.SubstateMod); Assert.Equal(WalkForward, state.Substate); // Rebuild happened (not the fast path): list = hop1(1) + hop2(1) + cycle(1) = 3 nodes. Assert.Equal(3, seq.Count); } // ── stance change → Branch 1 chain (H14) ──────────────────────────── [Fact] public void GetObjectSequence_StyleChange_BuildsExitLinkPlusStyleLinkPlusNewCycle() { var loader = new FakeLoader(); uint exitAnim = 0x03000040u, styleLinkAnim = 0x03000041u, newCycleAnim = 0x03000042u; loader.Register(exitAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(styleLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(newCycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; mt.StyleDefaults[(DRWMotionCommand)HandCombatStyle] = (DRWMotionCommand)Ready; // Current: NonCombat, playing WalkForward (NOT the style default). Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(0x03000043u, 30f)); // exit-link: WalkForward -> NonCombat's default (Ready) Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(exitAnim, 30f)); // style-to-style link: NonCombat's default (Ready) -> HandCombat's default (Ready) Fixtures.AddLink(mt, NonCombatStyle, Ready, HandCombatStyle, Fixtures.MakeMotionData(styleLinkAnim, 30f)); // HandCombat's default cycle (Ready) Fixtures.AddCycle(mt, HandCombatStyle, Ready, Fixtures.MakeMotionData(newCycleAnim, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); bool ok = cmt.GetObjectSequence(HandCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(HandCombatStyle, state.Style); Assert.Equal(Ready, state.Substate); // committed to the TARGET style's default substate // exit-link(1) + style-link(1) + new cycle(1) = 3 nodes Assert.Equal(3, seq.Count); } [Fact] public void GetObjectSequence_StyleChange_AlreadyInTargetStyle_IsNoOp() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(new NullLoader()); bool ok = cmt.GetObjectSequence(NonCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(0u, outTicks); Assert.Equal(0, seq.Count); // untouched } // ── emote-while-running Branch 3 out-and-back (A4-#1) ─────────────── [Fact] public void GetObjectSequence_ActionDirectLink_QueuesActionAndReAddsBaseCycle() { var loader = new FakeLoader(); uint cycleAnim = 0x03000050u, actionAnim = 0x03000051u; loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, RunForward, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); // Direct-link action path: action(1) + re-added base cycle(1) = 2 nodes. Assert.Equal(2, seq.Count); // Action queued onto the FIFO. Assert.Single(state.Actions); Assert.Equal(ThrustMed, state.Actions.First().Motion); // Base substate is NOT changed by an action (state.Substate stays RunForward). Assert.Equal(RunForward, state.Substate); // A3: outTicks = action's num_anims (1) only, direct-link path. Assert.Equal(1u, outTicks); } [Fact] public void GetObjectSequence_ActionNoDirectLink_FourLayerOutAndBack_BaseCycleAtOldSubstateMod() { var loader = new FakeLoader(); uint cycleAnim = 0x03000060u, outHopAnim = 0x03000061u, actionAnim = 0x03000062u, returnHopAnim = 0x03000063u; loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(outHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(returnHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f)); // NO direct RunForward -> ThrustMed link. Route via style default (Ready). Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(outHopAnim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, Ready, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, Ready, RunForward, Fixtures.MakeMotionData(returnHopAnim, 30f)); var cmt = new CMotionTable(mt); // Running at a NON-default speed so we can assert the base cycle is // re-added at the OLD substate_mod (A4-#1), not the action's speed. // Seed state DIRECTLY (bypassing GetObjectSequence) — this fixture's // RunForward<->Ready links would otherwise make a DoObjectMotion(RunForward) // seeding call itself take the double-hop rebuild path (RunForward // requested while already at RunForward, no direct self-link, empty // sequence so no fast-path) and pre-populate 3 nodes before the real // action call under test even runs. var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1.5f }; var seq = new CSequence(loader); CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1.5f); Assert.Equal(1, seq.Count); bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); // outHop(1) + action(1) + returnHop(1) + base cycle(1) = 4 nodes. Assert.Equal(4, seq.Count); Assert.Equal(RunForward, state.Substate); // action doesn't change base substate Assert.Equal(1.5f, state.SubstateMod); // untouched — base cycle re-added at OLD speed // Base cycle (the last-appended / cyclic tail node) keeps the OLD // substate_mod's framerate scale (1.5x of the dat's 30f == 45f). Assert.NotNull(seq.FirstCyclic); Assert.Equal(45.0, seq.FirstCyclic!.Framerate, 3); // A4-#1: outTicks = outHop.num_anims + action.num_anims + returnHop.num_anims // (NEVER the base cycle, NEVER double-counted — ACE's bug, not retail's). Assert.Equal(3u, outTicks); } // ── turn-in-place (Branch 2 cycle) ────────────────────────────────── [Fact] public void GetObjectSequence_TurnInPlace_ResolvesAsCycleFromReady() { var loader = new FakeLoader(); uint readyAnim = 0x03000070u, turnAnim = 0x03000071u, linkAnim = 0x03000072u; loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); // TurnRight is NOT gated (bitfield=0) in this fixture, isolating the // "resolves as a normal Branch-2 cycle" behavior from is_allowed gating // (that's the SEPARATE run-while-turning test below). Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 0)); Fixtures.AddLink(mt, NonCombatStyle, Ready, TurnRight, Fixtures.MakeMotionData(linkAnim, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(loader); bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false); Assert.True(ok); Assert.Equal(TurnRight, state.Substate); Assert.Equal(2, seq.Count); // link + cycle } // ── run-while-turning: is_allowed rejects gated turn cycle → Branch 4 ── // (AP-73 mechanism test, gap H4/H13) [Fact] public void GetObjectSequence_RunWhileTurning_GatedTurnCycleRejected_FallsToModifierPhysicsOnlyCombine() { var loader = new FakeLoader(); uint runAnim = 0x03000080u, turnAnim = 0x03000081u; loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var runVelocity = new Vector3(0, 4.0f, 0); Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f, velocity: runVelocity)); // TurnRight AS A CYCLE is bitfield&2 gated (substate-checked) — // is_allowed will reject it because current substate (RunForward) != // TurnRight and != the style default. Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 2)); // TurnRight AS A MODIFIER: physics-only turn omega, resolved from the // Modifiers dict once Branch 2 falls through. var turnOmega = new Vector3(0, 0, -(MathF.PI / 2f)); Fixtures.AddModifier(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(0, 0f, omega: turnOmega)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); int countBefore = seq.Count; var cyclicBefore = seq.FirstCyclic; var currBefore = seq.CurrAnim; bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false); Assert.True(ok); // Run anims UNTOUCHED — Branch 4 is physics-only, no add/remove of anim nodes. Assert.Equal(countBefore, seq.Count); Assert.Equal(cyclicBefore, seq.FirstCyclic); Assert.Equal(currBefore, seq.CurrAnim); // Base substate stays RunForward (a modifier doesn't replace the cycle). Assert.Equal(RunForward, state.Substate); // TurnRight now tracked as an active modifier. Assert.Contains(state.Modifiers, m => m.Motion == TurnRight); // Physics combined: velocity untouched (turn modifier carries no // velocity), omega now includes the turn contribution. Assert.Equal(runVelocity, seq.Velocity); Assert.Equal(turnOmega, seq.Omega); } // ── modifier stop = subtract + unlink ─────────────────────────────── [Fact] public void StopSequenceMotion_Modifier_SubtractsPhysicsAndUnlinksFromModifierChain() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var jumpOmega = new Vector3(0, 0, 5f); Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(new NullLoader()); // Activate the modifier directly (bypassing GetObjectSequence's gate logic). state.AddModifierNoCheck(Jump, 1f); seq.CombinePhysics(Vector3.Zero, jumpOmega); Assert.Equal(jumpOmega, seq.Omega); bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks); Assert.True(ok); Assert.Equal(Vector3.Zero, seq.Omega); // subtracted back out Assert.DoesNotContain(state.Modifiers, m => m.Motion == Jump); // unlinked Assert.Equal(0u, outTicks); } [Fact] public void StopSequenceMotion_UnknownModifier_ReturnsFalse_NoOp() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(new NullLoader()); bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks); Assert.False(ok); Assert.Equal(0u, outTicks); } // ── StopObjectCompletely drains modifiers then re-drives to style default (A4-#4) ── [Fact] public void StopObjectCompletely_DrainsAllModifiers_ThenRedrivesToStyleDefault() { var loader = new FakeLoader(); uint runAnim = 0x03000090u, readyAnim = 0x03000091u, exitLinkAnim = 0x03000092u; loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); var jumpOmega = new Vector3(1, 0, 0); Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); state.AddModifierNoCheck(Jump, 1f); seq.CombinePhysics(Vector3.Zero, jumpOmega); bool ok = cmt.StopObjectCompletely(state, seq, out _); Assert.True(ok); Assert.Empty(state.Modifiers); // drained Assert.Equal(Ready, state.Substate); // re-driven to style default // The turn/jump physics contribution is gone (subtracted during drain). Assert.Equal(Vector3.Zero, seq.Omega); } // ── missing cycle → return false, sequence UNTOUCHED (H5) ─────────── [Fact] public void GetObjectSequence_MissingCycle_ReturnsFalse_SequenceUntouched() { var loader = new FakeLoader(); uint readyAnim = 0x030000A0u; loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); // Deliberately NO RunForward cycle anywhere (not under NonCombatStyle, // not under DefaultStyle) — the default_style retry also misses. var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(Ready, state, seq, 1f, out _)); int countBefore = seq.Count; var velBefore = seq.Velocity; var omegaBefore = seq.Omega; uint substateBefore = state.Substate; bool ok = cmt.GetObjectSequence(RunForward, state, seq, 1f, out uint outTicks, stopCall: false); Assert.False(ok); Assert.Equal(0u, outTicks); Assert.Equal(countBefore, seq.Count); // list untouched Assert.Equal(velBefore, seq.Velocity); Assert.Equal(omegaBefore, seq.Omega); Assert.Equal(substateBefore, state.Substate); // state untouched } // ── entry guards ───────────────────────────────────────────────────── [Fact] public void GetObjectSequence_ZeroStyle_ReturnsFalse() { var mt = new MotionTable(); var cmt = new CMotionTable(mt); var state = new MotionState(); // Style=0, Substate=0 (default ctor) var seq = new CSequence(new NullLoader()); bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); Assert.False(ok); Assert.Equal(0u, outTicks); } [Fact] public void GetObjectSequence_ZeroSubstate_ReturnsFalse() { var mt = new MotionTable(); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = 0 }; var seq = new CSequence(new NullLoader()); bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); Assert.False(ok); Assert.Equal(0u, outTicks); } // ── modifier-class no-op fast path ────────────────────────────────── [Fact] public void GetObjectSequence_ModifierClassTargetEqualsStyleDefault_NotStopCall_IsNoOp() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Jump; // contrived: style default == Jump (modifier-class id) var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Jump, SubstateMod = 1f }; var seq = new CSequence(new NullLoader()); bool ok = cmt.GetObjectSequence(Jump, state, seq, 1f, out uint outTicks, stopCall: false); Assert.True(ok); Assert.Equal(0u, outTicks); Assert.Equal(0, seq.Count); // no-op, nothing built } // ── re_modify replays the modifier stack after a substate change ──── [Fact] public void ReModify_ReplaysActiveModifiers_ThroughGetObjectSequence() { var loader = new FakeLoader(); uint runAnim = 0x030000B0u; loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); var jumpOmega = new Vector3(0, 0, 9f); Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; var seq = new CSequence(loader); Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); // Active modifier BEFORE the transition that calls re_modify. state.AddModifierNoCheck(Jump, 1f); seq.ClearPhysics(); // simulate a fresh rebuild wiping physics cmt.ReModify(seq, state); // re_modify replayed Jump through GetObjectSequence -> combine_motion // re-applies its omega contribution. Assert.Equal(jumpOmega, seq.Omega); // Modifier chain popped-and-readded during replay; net membership unchanged. Assert.Contains(state.Modifiers, m => m.Motion == Jump); } [Fact] public void ReModify_EmptyModifierChain_IsNoOp() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(new NullLoader()); cmt.ReModify(seq, state); // must not throw; no modifiers to replay Assert.Empty(state.Modifiers); Assert.Equal(0, seq.Count); } // ── free-function unit coverage ───────────────────────────────────── [Theory] [InlineData(1f, 1f, true)] [InlineData(-1f, -1f, true)] [InlineData(1f, -1f, false)] [InlineData(-1f, 1f, false)] [InlineData(0f, 1f, true)] [InlineData(0f, -1f, false)] public void SameSign_MatchesRetailSemantics(float a, float b, bool expected) { Assert.Equal(expected, CMotionTable.SameSign(a, b)); } [Fact] public void ChangeCycleSpeed_RescalesFramerate_ByRatio() { var loader = new FakeLoader(); uint animId = 0x030000C0u; loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var seq = new CSequence(loader); seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); var md = Fixtures.MakeMotionData(animId, 10f); CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 1f, newSpeed: 2f); Assert.Equal(20.0, seq.CurrAnim!.Framerate, 3); } [Fact] public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNearZero_ZeroesFramerate() { // A4-#2: retail's own gap, ported verbatim — when BOTH old and new // speed are ~0, the framerate is explicitly zeroed. var loader = new FakeLoader(); uint animId = 0x030000C1u; loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var seq = new CSequence(loader); seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); var md = Fixtures.MakeMotionData(animId, 10f); CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 0.0001f); Assert.Equal(0.0, seq.CurrAnim!.Framerate, 5); } [Fact] public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNonZero_SilentNoOp_A4Gap() { // A4-#2 gap, PORTED VERBATIM (retail bug, not ours to fix): when // old speed ~0 but new speed is NOT ~0, retail's fabsl(arg4) branch // structure suppresses the rescale entirely — framerate is left // exactly as-is (no zeroing, no rescale). Leave this test failing // rather than fudge the assertion if the decomp's fall-through is // ever re-read differently. var loader = new FakeLoader(); uint animId = 0x030000C2u; loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var seq = new CSequence(loader); seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); var md = Fixtures.MakeMotionData(animId, 10f); CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 5f); Assert.Equal(10.0, seq.CurrAnim!.Framerate, 5); } [Fact] public void AddMotion_NullMotionData_IsNoOp() { var seq = new CSequence(new NullLoader()); CMotionTable.AddMotion(seq, null, 1f); Assert.Equal(0, seq.Count); Assert.Equal(Vector3.Zero, seq.Velocity); } [Fact] public void AddMotion_SetsVelocityOmega_Unconditionally_EvenWhenZero() { // G17 core: add_motion is UNCONDITIONAL (retail 0x005224b0) — no // HasVelocity/HasOmega gate. A MotionData with a non-zero existing // sequence velocity gets overwritten with the dat-silent zero. var loader = new FakeLoader(); uint animId = 0x030000D0u; loader.Register(animId, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var seq = new CSequence(loader); seq.SetVelocity(new Vector3(9, 9, 9)); var md = Fixtures.MakeMotionData(animId, 10f); // no HasVelocity flag -> Velocity field is default(Vector3.Zero) CMotionTable.AddMotion(seq, md, 1f); Assert.Equal(Vector3.Zero, seq.Velocity); // overwritten with zero, not left at (9,9,9) } [Fact] public void CombineMotion_AddsToExistingPhysics_AnimsUntouched() { var seq = new CSequence(new NullLoader()); seq.SetVelocity(new Vector3(1, 0, 0)); var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0)); CMotionTable.CombineMotion(seq, md, 1f); Assert.Equal(new Vector3(3, 0, 0), seq.Velocity); Assert.Equal(0, seq.Count); // combine_motion never touches anims } [Fact] public void SubtractMotion_RemovesFromExistingPhysics_AnimsUntouched() { var seq = new CSequence(new NullLoader()); seq.SetVelocity(new Vector3(5, 0, 0)); var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0)); CMotionTable.SubtractMotion(seq, md, 1f); Assert.Equal(new Vector3(3, 0, 0), seq.Velocity); Assert.Equal(0, seq.Count); } // ── GetLink / IsAllowed direct unit coverage ──────────────────────── [Fact] public void GetLink_ForwardDirection_LooksUpBySubstateThenMotion() { var mt = new MotionTable(); var link = Fixtures.MakeMotionData(0x030000E0u, 30f); Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link); var cmt = new CMotionTable(mt); var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, 1f); Assert.Same(link, result); } [Fact] public void GetLink_ReversedDirection_EitherSpeedNegative_SwapsKeys() { // A1 pin: EITHER speed negative -> swapped-key branch (link stored // FROM motion TO substate). var mt = new MotionTable(); var reversedLink = Fixtures.MakeMotionData(0x030000E1u, 30f); Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, reversedLink); var cmt = new CMotionTable(mt); // fromSubstate=Ready(+1), toSubstate=WalkForward but NEGATIVE speed. var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, -1f); Assert.Same(reversedLink, result); } [Fact] public void IsAllowed_UngatedMotionData_AlwaysAllowed() { var mt = new MotionTable(); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; var md = Fixtures.MakeMotionData(0x030000F0u, 30f, bitfield: 0); Assert.True(cmt.IsAllowed(WalkForward, md, state)); } [Fact] public void IsAllowed_GatedMotionData_CandidateEqualsCurrentSubstate_Allowed() { var mt = new MotionTable(); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = TurnRight }; var md = Fixtures.MakeMotionData(0x030000F1u, 30f, bitfield: 2); Assert.True(cmt.IsAllowed(TurnRight, md, state)); } [Fact] public void IsAllowed_GatedMotionData_CurrentSubstateIsStyleDefault_Allowed() { var mt = new MotionTable(); mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; var md = Fixtures.MakeMotionData(0x030000F2u, 30f, bitfield: 2); Assert.True(cmt.IsAllowed(TurnRight, md, state)); } [Fact] public void IsAllowed_GatedMotionData_CurrentSubstateMismatch_Rejected() { var mt = new MotionTable(); mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = RunForward }; // neither TurnRight nor Ready var md = Fixtures.MakeMotionData(0x030000F3u, 30f, bitfield: 2); Assert.False(cmt.IsAllowed(TurnRight, md, state)); } [Fact] public void IsAllowed_NullMotionData_Rejected() { var mt = new MotionTable(); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; Assert.False(cmt.IsAllowed(TurnRight, null, state)); } // ── SetDefaultState (hard reset) ───────────────────────────────────── [Fact] public void SetDefaultState_ResetsToTableDefaultStyleAndSubstate_ClearAnimationsHardReset() { var loader = new FakeLoader(); uint readyAnim = 0x03000100u, junkAnim = 0x03000101u; loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(junkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = HandCombatStyle, Substate = RunForward, SubstateMod = 3f }; state.AddModifierNoCheck(Jump, 1f); state.AddAction(ThrustMed, 1f); var seq = new CSequence(loader); seq.AppendAnimation(Fixtures.MakeAnimData(junkAnim, 5f)); // stale junk node bool ok = cmt.SetDefaultState(state, seq, out uint outTicks); Assert.True(ok); Assert.Equal(NonCombatStyle, state.Style); Assert.Equal(Ready, state.Substate); Assert.Equal(1f, state.SubstateMod); Assert.Empty(state.Modifiers); // clear_modifiers Assert.Empty(state.Actions); // clear_actions // clear_animations hard reset: the stale junk node is gone; only the // fresh Ready cycle remains. Assert.Equal(1, seq.Count); // outTicks = cyclic.num_anims - 1 = 1 - 1 = 0 (decomp §8: *arg4 = node->motionData->num_anims - 1). Assert.Equal(0u, outTicks); } [Fact] public void SetDefaultState_TableHasNoDefaultStyleEntry_ReturnsFalse() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; // No StyleDefaults entry registered for NonCombatStyle. var cmt = new CMotionTable(mt); var state = new MotionState(); var seq = new CSequence(new NullLoader()); bool ok = cmt.SetDefaultState(state, seq, out uint outTicks); Assert.False(ok); Assert.Equal(0u, outTicks); } // ── DoObjectMotion / StopObjectMotion thin-wrapper coverage ────────── [Fact] public void DoObjectMotion_ForcesStopFlagFalse_DelegatesToGetObjectSequence() { var loader = new FakeLoader(); uint animId = 0x03000110u; loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(animId, 30f)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; var seq = new CSequence(loader); bool ok = cmt.DoObjectMotion(Ready, state, seq, 1f, out _); Assert.True(ok); Assert.Equal(1, seq.Count); } [Fact] public void StopObjectMotion_DelegatesToStopSequenceMotion() { var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; var jumpOmega = new Vector3(0, 0, 3f); Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); var cmt = new CMotionTable(mt); var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; state.AddModifierNoCheck(Jump, 1f); var seq = new CSequence(new NullLoader()); seq.CombinePhysics(Vector3.Zero, jumpOmega); bool ok = cmt.StopObjectMotion(Jump, 1f, state, seq, out _); Assert.True(ok); Assert.Equal(Vector3.Zero, seq.Omega); } } /// In-memory that never resolves. file sealed class NullLoader : IAnimationLoader { public Animation? LoadAnimation(uint id) => null; } /// In-memory test double. file sealed class FakeLoader : IAnimationLoader { private readonly System.Collections.Generic.Dictionary _anims = new(); public void Register(uint id, Animation anim) => _anims[id] = anim; public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; }