acdream/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs
Erik 3b9d9bb6be feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED
AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:41:27 +02:00

380 lines
16 KiB
C#

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);
// EXPECTED-DIFF(Q4): pre-cutover Fix B stripped every link leaving
// "102@60.0*^". The verbatim GetObjectSequence (Branch 2, no direct
// Walk->Run link in this fixture) routes the DOUBLE-HOP: Walk->Ready
// settle (105 at the OLD substate mod) + Ready->Run windup (104 at
// the new speed) + the Run cycle; the old Ready->Walk link (103)
// keeps draining first (pending-queue discipline). Rapid same-motion
// re-issues now collapse via remove_redundant_links (the retail
// Fix B), not an adapter locomotion special case.
Assert.Equal(
"103@30.0^,105@30.0,104@60.0,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);
// EXPECTED-DIFF(Q4): inherits S3's double-hop list; the re-speed
// itself is the verbatim Branch-2 fast path (change_cycle_speed
// scales ONLY first_cyclic..tail: 102 60->75; links untouched;
// velocity via subtract_motion(2.0)+combine_motion(2.5)).
Assert.Equal(
"103@30.0^,105@30.0,104@60.0,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);
// Node list BYTE-IDENTICAL to pre-cutover: the reversed-key get_link
// resolves the Walk->Ready link (0x105) played in reverse (fr=-19.5 =
// 30 x -0.65 BackwardsFactor); cursor at the reverse starting frame
// (HighFrame+1 = 3).
// EXPECTED-DIFF(Q4), mirrors only: CurrentMotion now reads the
// POST-adjust_motion substate (45000005, was 45000006) and
// CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns
// the state and retail's interpreted state is post-adjustment. The
// om=(-0.00,...) is IEEE negative zero from add_motion's
// zero-omega x negative-speed multiply (numerically equal to 0).
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=45000005 mod=-0.65",
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);
// EXPECTED-DIFF(Q4): pre-cutover the stop-anim low-byte fallback
// re-keyed the settle as 105@30 (forward). The verbatim get_link
// (SubstateMod=-0.65 routes the REVERSED-key branch) resolves the
// Ready->Walk windup (103) played in REVERSE (fr=-30) - retail's
// actual backward-walk settle. The old reversed link (105@-19.5,
// mid-drain) still drains first, unchanged from pre-cutover.
Assert.Equal(
"105@-19.5^,103@-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);
// EXPECTED-DIFF(Q4): pre-cutover PlayAction INSERTED the modifier's
// anim (109) before the cyclic tail - an acdream invention. Retail
// Branch 4 is PHYSICS-ONLY combine_motion (the AP-73 mechanism): the
// walk cycle's frames are untouched, the modifier contributes omega
// (0,0,1.5) and is tracked on the MotionState modifier stack.
Assert.Equal(
"103@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);
// EXPECTED-DIFF(Q4): pre-cutover switched cycles bare ("107@30.0*^").
// GetObjectSequence Branch 1 (style-change) plays the cross-style
// entry link (10A = Ready->Style2) before the target style's default
// cycle - retail's stance-transition animation.
Assert.Equal(
"10A@30.0^,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));
}
}