acdream/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
Erik a6235a36f5 test(R2-Q4a): pre-cutover adapter trace goldens
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 <noreply@anthropic.com>
2026-07-02 21:12:15 +02:00

350 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// `<animIdHex>@<framerate:F1>` with suffix `*` = first_cyclic, `^` = curr_anim;
// then ` | frame=<F1> vel=(x,y,z F2) om=(x,y,z F2) style=<X8> motion=<X8>
// mod=<F2>`.
// ─────────────────────────────────────────────────────────────────────────────
internal sealed class TraceLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _anims = new();
private readonly Dictionary<Animation, uint> _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<Animation> 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));
}
}