From a6235a36f55e4c0b186d7b567a817728a8eb22a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 21:12:15 +0200 Subject: [PATCH] test(R2-Q4a): pre-cutover adapter trace goldens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 scripted scenarios (spawn idle, idle→walk link, walk→run cyclic-to- cyclic, fast re-speed, backward-walk remap, stop-settle fallback, emote action, K-fix18 skip-link, turn modifier, style change, link-drain sentinel count) snapshotting the CSequence core's list state after every SetCycle/PlayAction against the LEGACY adapter (fast-path + Fix B + stop-anim fallback + G17 gate). These are the parity bar for the Q4 PerformMovement cutover — intentional post-cutover changes get EXPECTED-DIFF annotations, everything else must stay byte-identical. Notable pre-cutover behavior captured: the S6 link-before-link stacking (old reversed link kept mid-drain while the settle link appends behind). Adds the internal AnimationSequencer.Core test seam. Co-Authored-By: Claude Fable 5 --- .../Physics/AnimationSequencer.cs | 3 + .../AnimationSequencerCutoverTraceTests.cs | 350 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs 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)); + } +}