diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
index b830c049..073d4365 100644
--- a/src/AcDream.Core/Physics/AnimationSequencer.cs
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -159,6 +159,9 @@ public sealed class AnimationSequencer
public int QueueCount => _core.Count;
public bool HasCurrentNode => _core.CurrAnim != null;
+ /// Test seam (Q4 trace conformance): the verbatim CSequence core.
+ internal CSequence Core => _core;
+
///
/// Diagnostic snapshot of the core's curr_anim identity + frame
/// state, for the per-tick CURRNODE log line in
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
new file mode 100644
index 00000000..590d6461
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
@@ -0,0 +1,350 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+using Xunit;
+
+using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// R2-Q4 adapter-cutover trace conformance (r2-port-plan.md §3 Q4).
+//
+// These scenarios were CAPTURED against the PRE-cutover adapter (the legacy
+// SetCycle/PlayAction with the fast-path, Fix B, stop-anim fallback, and G17
+// gate) at commit aa65990a, then replayed against the PerformMovement-hosted
+// adapter. Golden strings assert the post-Q4 behavior; every place the cutover
+// INTENTIONALLY changed the outcome carries an `EXPECTED-DIFF(Q4)` comment
+// with the pre-cutover value and the retail rationale. Everything without an
+// annotation is byte-identical across the cutover — that is the parity bar.
+//
+// Snapshot format (Describe): comma-joined node list in queue order, each node
+// `@` with suffix `*` = first_cyclic, `^` = curr_anim;
+// then ` | frame= vel=(x,y,z F2) om=(x,y,z F2) style= motion=
+// mod=`.
+// ─────────────────────────────────────────────────────────────────────────────
+
+internal sealed class TraceLoader : IAnimationLoader
+{
+ private readonly Dictionary _anims = new();
+ private readonly Dictionary _ids = new();
+
+ public void Register(uint id, Animation anim)
+ {
+ _anims[id] = anim;
+ _ids[anim] = id;
+ }
+
+ public Animation? LoadAnimation(uint id) =>
+ _anims.TryGetValue(id, out var a) ? a : null;
+
+ public uint IdOf(Animation anim) => _ids.TryGetValue(anim, out var id) ? id : 0;
+}
+
+public sealed class AnimationSequencerCutoverTraceTests
+{
+ // Styles (FULL command words, as GameWindow passes them).
+ private const uint NC = 0x8000003Du; // NonCombat
+ private const uint Style2 = 0x8000004Cu; // synthetic second style
+
+ // Substates / cycles.
+ private const uint Ready = 0x41000003u;
+ private const uint Walk = 0x45000005u;
+ private const uint WalkBack = 0x45000006u;
+ private const uint Run = 0x44000007u;
+ private const uint Falling = 0x40000015u;
+
+ // Action / modifier class ids.
+ private const uint EmoteAction = 0x10000062u;
+ private const uint TurnMod = 0x6500000Du;
+
+ // Anim resource ids.
+ private const uint ReadyAnim = 0x100u; // 4 frames
+ private const uint WalkAnim = 0x101u; // 6 frames
+ private const uint RunAnim = 0x102u; // 6 frames
+ private const uint ReadyToWalkLink = 0x103u; // 2 frames
+ private const uint ReadyToRunLink = 0x104u; // 2 frames
+ private const uint WalkToReadyLink = 0x105u; // 3 frames
+ private const uint RunToReadyLink = 0x108u; // 3 frames
+ private const uint TurnModAnim = 0x109u; // 2 frames
+ private const uint ReadyToStyle2Link = 0x10Au; // 2 frames
+ private const uint ReadyToEmoteLink = 0x10Bu; // 5 frames
+ private const uint FallAnim = 0x10Cu; // 4 frames
+ private const uint Style2ReadyAnim = 0x107u; // 4 frames
+
+ private static Animation MakeAnim(int numFrames, int numParts = 1)
+ {
+ 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 = Vector3.Zero, Orientation = Quaternion.Identity });
+ anim.PartFrames.Add(pf);
+ }
+ return anim;
+ }
+
+ private static MotionData MakeMd(uint animId, float framerate = 30f,
+ Vector3? velocity = null, Vector3? omega = null)
+ {
+ var md = new MotionData();
+ QualifiedDataId qid = animId;
+ md.Anims.Add(new AnimData
+ {
+ AnimId = qid,
+ LowFrame = 0,
+ HighFrame = -1,
+ Framerate = framerate,
+ });
+ if (velocity is { } v)
+ {
+ md.Velocity = v;
+ md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity;
+ }
+ if (omega is { } o)
+ {
+ md.Omega = o;
+ md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega;
+ }
+ return md;
+ }
+
+ private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md)
+ {
+ int outer = (int)((style << 16) | (from & 0xFFFFFFu));
+ if (!mt.Links.TryGetValue(outer, out var cmd))
+ {
+ cmd = new MotionCommandData();
+ mt.Links[outer] = cmd;
+ }
+ cmd.MotionData[(int)to] = md;
+ }
+
+ private static (Setup, MotionTable, TraceLoader) BuildFixture()
+ {
+ var setup = new Setup();
+ setup.Parts.Add(0x01000000u);
+ setup.DefaultScale.Add(Vector3.One);
+
+ var loader = new TraceLoader();
+ loader.Register(ReadyAnim, MakeAnim(4));
+ loader.Register(WalkAnim, MakeAnim(6));
+ loader.Register(RunAnim, MakeAnim(6));
+ loader.Register(ReadyToWalkLink, MakeAnim(2));
+ loader.Register(ReadyToRunLink, MakeAnim(2));
+ loader.Register(WalkToReadyLink, MakeAnim(3));
+ loader.Register(RunToReadyLink, MakeAnim(3));
+ loader.Register(TurnModAnim, MakeAnim(2));
+ loader.Register(ReadyToStyle2Link, MakeAnim(2));
+ loader.Register(ReadyToEmoteLink, MakeAnim(5));
+ loader.Register(FallAnim, MakeAnim(4));
+ loader.Register(Style2ReadyAnim, MakeAnim(4));
+
+ var mt = new MotionTable
+ {
+ DefaultStyle = (DRWMotionCommand)NC,
+ };
+ mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
+ mt.StyleDefaults[(DRWMotionCommand)Style2] = (DRWMotionCommand)Ready;
+
+ int CycleKey(uint style, uint substate) => (int)((style << 16) | (substate & 0xFFFFFFu));
+ mt.Cycles[CycleKey(NC, Ready)] = MakeMd(ReadyAnim);
+ mt.Cycles[CycleKey(NC, Walk)] = MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f));
+ mt.Cycles[CycleKey(NC, Run)] = MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f));
+ mt.Cycles[CycleKey(NC, Falling)] = MakeMd(FallAnim);
+ mt.Cycles[CycleKey(Style2, Ready)] = MakeMd(Style2ReadyAnim);
+
+ AddLink(mt, NC, Ready, Walk, MakeMd(ReadyToWalkLink));
+ AddLink(mt, NC, Ready, Run, MakeMd(ReadyToRunLink));
+ AddLink(mt, NC, Walk, Ready, MakeMd(WalkToReadyLink));
+ AddLink(mt, NC, Run, Ready, MakeMd(RunToReadyLink));
+ AddLink(mt, NC, Ready, EmoteAction, MakeMd(ReadyToEmoteLink));
+ AddLink(mt, NC, Ready, Style2, MakeMd(ReadyToStyle2Link));
+
+ // Modifier: styled key (styleMasked<<16 | low24).
+ int modKey = (int)((NC << 16) | (TurnMod & 0xFFFFFFu));
+ mt.Modifiers[modKey] = MakeMd(TurnModAnim, omega: new Vector3(0f, 0f, 1.5f));
+
+ return (setup, mt, loader);
+ }
+
+ private static string Describe(AnimationSequencer seq, TraceLoader loader)
+ {
+ var core = seq.Core;
+ var sb = new StringBuilder();
+ bool first = true;
+ for (var n = core.AnimList.First; n is not null; n = n.Next)
+ {
+ if (!first) sb.Append(',');
+ first = false;
+ uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim);
+ sb.Append($"{id:X}@{n.Value.Framerate:F1}");
+ if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*');
+ if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^');
+ }
+ var v = core.Velocity;
+ var o = core.Omega;
+ sb.Append($" | frame={core.FrameNumber:F1}");
+ sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
+ sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
+ sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
+ return sb.ToString();
+ }
+
+ private static AnimationSequencer NewSeq(out TraceLoader loader)
+ {
+ var (setup, mt, l) = BuildFixture();
+ loader = l;
+ return new AnimationSequencer(setup, mt, l);
+ }
+
+ // ── Scenarios ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public void S1_SpawnIdle()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ Assert.Equal(
+ "100@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S2_IdleToWalk_LinkThenCycle()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+ Assert.Equal(
+ "103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,0.00) style=8000003D motion=45000005 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S2b_LinkDrain_FiresOneAnimDoneSentinel()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+
+ // Drain the 2-frame link at fr=30: 2 frames / 30fps < 0.1s. Advance
+ // enough to cross into the cycle.
+ int animDone = 0;
+ for (int i = 0; i < 10; i++)
+ {
+ seq.Advance(0.02f);
+ foreach (var h in seq.ConsumePendingHooks())
+ if (h is DatReaderWriter.Types.AnimationDoneHook)
+ animDone++;
+ }
+ Assert.Equal(1, animDone);
+ }
+
+ [Fact]
+ public void S3_WalkToRun_CyclicToCyclic()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+ seq.SetCycle(NC, Run, 2.0f);
+ Assert.Equal(
+ "102@60.0*^ | frame=0.0 vel=(0.00,8.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S4_RunReSpeed_FastPath()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+ seq.SetCycle(NC, Run, 2.0f);
+ seq.SetCycle(NC, Run, 2.5f);
+ Assert.Equal(
+ "102@75.0*^ | frame=0.0 vel=(0.00,10.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.50",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S5_WalkBackward_RemapNegativeSpeed()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, WalkBack, 1.0f);
+ // Reversed-key GetLink resolves the Walk→Ready link (0x105) played in
+ // reverse (fr=-19.5 = 30 × -0.65 BackwardsFactor); cursor starts at
+ // the link's reverse starting frame (HighFrame+1 = 3).
+ Assert.Equal(
+ "105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(0.00,0.00,0.00) style=8000003D motion=45000006 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S6_WalkBackToReady_StopSettleFallback()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, WalkBack, 1.0f);
+ seq.SetCycle(NC, Ready, 1.0f);
+ // Pre-cutover: the OLD reversed link (0x105@-19.5, mid-drain) stays at
+ // the head while the settle link (0x105@30, via the stop-anim
+ // low-byte fallback 0x06→0x05) and the Ready cycle append behind it —
+ // link-before-link stacking.
+ Assert.Equal(
+ "105@-19.5^,105@30.0,100@30.0* | frame=3.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S7_EmoteAction_MidReady()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.PlayAction(EmoteAction, 1.0f);
+ Assert.Equal(
+ "10B@30.0^,100@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S8_KFix18_FallingSkipsLink()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+ seq.SetCycle(NC, Falling, 1.0f, skipTransitionLink: true);
+ Assert.Equal(
+ "10C@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=40000015 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S9_TurnModifier()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(NC, Walk, 1.0f);
+ seq.PlayAction(TurnMod, 1.0f);
+ Assert.Equal(
+ "103@30.0^,109@30.0,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,1.50) style=8000003D motion=45000005 mod=1.00",
+ Describe(seq, loader));
+ }
+
+ [Fact]
+ public void S10_StyleChange()
+ {
+ var seq = NewSeq(out var loader);
+ seq.SetCycle(NC, Ready);
+ seq.SetCycle(Style2, Ready, 1.0f);
+ Assert.Equal(
+ "107@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000004C motion=41000003 mod=1.00",
+ Describe(seq, loader));
+ }
+}