acdream/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs
Erik aa65990a1d feat(R2-Q3): verbatim MotionTableManager — the pending-animation queue (closes H3, H15-core)
Ports retail's MotionTableManager (0x0051bxxx; r2-motiontable-decomp.md
§11) above the Q2 CMotionTable: add_to_queue 0x0051bfe0 (append +
immediate collapse), remove_redundant_links 0x0051bf20 (TAIL-ANCHORED
single scan, NOT ACE's restructured loop — cycle-tail block mask
0xb0000000 = STYLE|MODIFIER|ACTION, style-tail 0x70000000 =
CYCLE|MODIFIER|ACTION; the decomp's prose gloss of those masks was
imprecise, the literal constants are ported), truncate_animation_list
0x0051bca0 (zero NumAnims IN PLACE from the tail through the matched
node's successor + CSequence.RemoveLinkAnimations), AnimationDone
0x0051bce0 (counter-driven countdown chain: one call can pop MULTIPLE
entries; action-class pops MotionState's action FIFO; drained-list
counter reset), CheckForCompletedMotions 0x0051be00 / UseTime (zero-tick
sweep, success hardcoded true), initialize_state 0x0051c030 (0x41000003
sentinel), HandleEnterWorld/HandleExitWorld drains (MotionDone
success:false; enter also strips link anims), PerformMovement 0x0051c0b0
(error codes 7 / 0x43 / 0; StopCompletely queues the Ready sentinel
UNCONDITIONALLY).

IMotionDoneSink is the R2 seam standing in for CPhysicsObj::MotionDone —
R3 binds MotionInterpreter.MotionDone (r2-port-plan.md §4 contract).

This queue + remove_redundant_links IS the retail mechanism our old
'Fix B' rapid-motion collapse approximated — Q4 deletes Fix B and routes
SetCycle/PlayAction through PerformMovement.

47 conformance tests: countdown-chain tables, truncate blocked/allowed
matrices for both masks, zero-tick vs counter sweep, world drains, the
walk-run-walk-run collapse trace, the 2026-05-03 walk-to-run golden
(both nodes queued, truncate not firing), PerformMovement error matrix.

Register: AD-34 extended (pending_animations managed LinkedList);
AD-35 added (NotHandled sentinel vs retail's dead-code pointer-leak
default case).

Implemented by a dedicated agent against the committed Q3 spec; diff
scope, mask semantics, truncation range, counter reset, and
PerformMovement paths independently verified against the decomp raw
text before commit. Build + full suite green (3,447 passed).

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

1018 lines
44 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Xunit;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Tests.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// MotionTableManagerTests — R2-Q3 conformance harness for the verbatim
// MotionTableManager pending-animation queue + tick-countdown completion
// machinery (r2-port-plan.md §3 Q3; oracle
// docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11).
//
// Fixture convention matches CMotionTableTests.cs's file-local Fixtures
// class. Most tests exercise MotionTableManager directly via its
// PendingAnimations/AnimationCounter inspection surface + a RecordingSink,
// bypassing GetObjectSequence entirely so the queue mechanics are pinned in
// isolation from the selection dispatcher (already covered by
// CMotionTableTests.cs).
// ─────────────────────────────────────────────────────────────────────────────
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<Animation>)animId,
LowFrame = 0,
HighFrame = -1,
Framerate = framerate,
};
public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0)
{
var md = new MotionData { Bitfield = bitfield };
md.Anims.Add(MakeAnimData(animId, 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;
}
}
/// <summary>Records every <see cref="IMotionDoneSink.MotionDone"/> call in order.</summary>
internal sealed class RecordingSink : IMotionDoneSink
{
public readonly List<(uint Motion, bool Success)> Calls = new();
public void MotionDone(uint motion, bool success) => Calls.Add((motion, success));
}
/// <summary>In-memory <see cref="IAnimationLoader"/> that never resolves.</summary>
file sealed class NullLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
/// <summary>In-memory <see cref="IAnimationLoader"/> test double.</summary>
file sealed class FakeLoader : IAnimationLoader
{
private readonly Dictionary<uint, Animation> _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;
}
public sealed class MotionTableManagerTests
{
// Real retail command words (DatReaderWriter.Enums.MotionCommand),
// matching CMotionTableTests.cs's fixture conventions.
private const uint Ready = 0x41000003u; // cycle, == ReadySentinel
private const uint WalkForward = 0x45000005u; // cycle
private const uint RunForward = 0x44000007u; // cycle
private const uint ThrustMed = 0x10000058u; // action only
private const uint NonCombatStyle = 0x8000003Du; // style, top bit set
private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set
private static (MotionTableManager mgr, RecordingSink sink, MotionState state, CSequence seq) MakeManager(CMotionTable? table = null)
{
var sink = new RecordingSink();
var state = new MotionState();
var seq = new CSequence(new NullLoader());
var mgr = new MotionTableManager(table, state, seq, sink);
return (mgr, sink, state, seq);
}
// ── add_to_queue / basic queueing ───────────────────────────────────
[Fact]
public void AddToQueue_AppendsNode_InOrder()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 3);
mgr.AddToQueue(RunForward, 5);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(WalkForward, list[0].Motion);
Assert.Equal(3u, list[0].NumAnims);
Assert.Equal(RunForward, list[1].Motion);
Assert.Equal(5u, list[1].NumAnims);
}
// ── countdown-chain: ONE AnimationDone completing MULTIPLE entries ──
[Fact]
public void AnimationDone_CounterRollover_CompletesMultipleQueuedEntries_InOneCall()
{
// Two zero-duration-relative-to-each-other entries stacked so a
// single tick (counter=1) pops BOTH: first has NumAnims=1 (<=1 on
// the first increment), and after popping it counter -= 1 => back to
// 0... but decomp's while-loop re-tests the NEW head against the
// SAME counter value within the same AnimationDone call (no re-
// increment between pops) — so a second entry with NumAnims<=0
// (i.e. 0) also pops in the same call.
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 1); // pops when counter reaches 1
mgr.AddToQueue(RunForward, 0); // zero-duration -> pops same call once counter>=0
mgr.AnimationDone(true);
Assert.Equal(2, sink.Calls.Count);
Assert.Equal((WalkForward, true), sink.Calls[0]);
Assert.Equal((RunForward, true), sink.Calls[1]);
Assert.Empty(mgr.PendingAnimations);
// Drained-list counter reset.
Assert.Equal(0, mgr.AnimationCounter);
}
[Fact]
public void AnimationDone_MultiTickCountdown_PopsOnlyWhenCounterReachesDuration()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 3); // needs counter >= 3 to pop
mgr.AnimationDone(true); // counter=1
Assert.Empty(sink.Calls);
Assert.Single(mgr.PendingAnimations);
Assert.Equal(1, mgr.AnimationCounter);
mgr.AnimationDone(true); // counter=2
Assert.Empty(sink.Calls);
Assert.Equal(2, mgr.AnimationCounter);
mgr.AnimationDone(true); // counter=3 -> pops, counter -= 3 -> 0, list drains -> reset stays 0
Assert.Single(sink.Calls);
Assert.Equal((WalkForward, true), sink.Calls[0]);
Assert.Empty(mgr.PendingAnimations);
Assert.Equal(0, mgr.AnimationCounter);
}
[Fact]
public void AnimationDone_LeftoverCounter_ResetOnlyWhenListFullyDrains()
{
// Two entries: first pops on tick 1 (duration 1), second needs
// duration 5. After the first pop, counter = 1 - 1 = 0, but the
// list is NOT empty (second entry remains) -> counter must NOT be
// force-reset to something else; it stays at the algebraic value (0).
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 1);
mgr.AddToQueue(RunForward, 5);
mgr.AnimationDone(true); // counter=1 -> pops Walk (1<=1), counter -= 1 = 0; Run(5) remains, 0<=5? no.
Assert.Single(sink.Calls);
Assert.Equal((WalkForward, true), sink.Calls[0]);
Assert.Single(mgr.PendingAnimations);
Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion);
Assert.Equal(0, mgr.AnimationCounter);
}
[Fact]
public void AnimationDone_EmptyQueue_IsNoOp()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AnimationDone(true);
Assert.Empty(sink.Calls);
Assert.Equal(0, mgr.AnimationCounter);
}
// ── zero-tick sweep (CheckForCompletedMotions) vs counter sweep (AnimationDone) ──
[Fact]
public void CheckForCompletedMotions_PopsOnlyZeroTickHeads_NeverIncrementsCounter()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 0); // zero-tick: pops immediately
mgr.AddToQueue(RunForward, 2); // non-zero: stays
mgr.CheckForCompletedMotions();
Assert.Single(sink.Calls);
Assert.Equal((WalkForward, true), sink.Calls[0]); // success hardcoded true
Assert.Single(mgr.PendingAnimations);
Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion);
Assert.Equal(0, mgr.AnimationCounter); // untouched
}
[Fact]
public void CheckForCompletedMotions_NonZeroHead_DoesNotPop()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 4);
mgr.CheckForCompletedMotions();
Assert.Empty(sink.Calls);
Assert.Single(mgr.PendingAnimations);
}
[Fact]
public void UseTime_IsAliasFor_CheckForCompletedMotions()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 0);
mgr.UseTime();
Assert.Single(sink.Calls);
Assert.Equal((WalkForward, true), sink.Calls[0]);
}
[Fact]
public void AnimationDone_Distinguished_FromCheckForCompletedMotions_ByCounterUse()
{
// A node with NumAnims=1 does NOT pop via CheckForCompletedMotions
// (which only pops EXACTLY zero), but DOES pop via one AnimationDone
// tick (counter reaches 1).
var (mgrA, sinkA, _, _) = MakeManager();
mgrA.AddToQueue(WalkForward, 1);
mgrA.CheckForCompletedMotions();
Assert.Empty(sinkA.Calls);
Assert.Single(mgrA.PendingAnimations);
var (mgrB, sinkB, _, _) = MakeManager();
mgrB.AddToQueue(WalkForward, 1);
mgrB.AnimationDone(true);
Assert.Single(sinkB.Calls);
Assert.Empty(mgrB.PendingAnimations);
}
// ── action-class completion pops MotionState's action FIFO ──────────
[Fact]
public void AnimationDone_ActionClassMotion_PopsActionHead()
{
var (mgr, sink, state, _) = MakeManager();
state.AddAction(ThrustMed, 1f);
Assert.Single(state.Actions);
mgr.AddToQueue(ThrustMed, 0); // zero-duration -> pops on first tick
mgr.AnimationDone(true);
Assert.Empty(state.Actions); // popped
Assert.Single(sink.Calls);
Assert.Equal((ThrustMed, true), sink.Calls[0]);
}
[Fact]
public void CheckForCompletedMotions_ActionClassMotion_PopsActionHead()
{
var (mgr, sink, state, _) = MakeManager();
state.AddAction(ThrustMed, 1f);
mgr.AddToQueue(ThrustMed, 0);
mgr.CheckForCompletedMotions();
Assert.Empty(state.Actions);
Assert.Single(sink.Calls);
}
[Fact]
public void AnimationDone_NonActionClassMotion_DoesNotTouchActionFifo()
{
var (mgr, _, state, _) = MakeManager();
state.AddAction(ThrustMed, 1f);
mgr.AddToQueue(WalkForward, 0); // cycle-class, not action-class
mgr.AnimationDone(true);
Assert.Single(state.Actions); // untouched
}
// ── enter/exit-world drains fire MotionDone(success=false) ──────────
[Fact]
public void HandleExitWorld_DrainsEntireQueue_FiringMotionDoneFalse_ForEveryEntry()
{
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 0);
mgr.AddToQueue(RunForward, 0);
mgr.AddToQueue(ThrustMed, 0);
mgr.HandleExitWorld();
Assert.Equal(3, sink.Calls.Count);
Assert.All(sink.Calls, c => Assert.False(c.Success));
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void HandleExitWorld_NonZeroDurationEntries_StillFullyDrained()
{
// Drain loops AnimationDone(false) until head_==0, incrementing the
// counter each call — even non-zero-duration entries eventually pop.
var (mgr, sink, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 3);
mgr.HandleExitWorld();
Assert.Single(sink.Calls);
Assert.Equal((WalkForward, false), sink.Calls[0]);
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void HandleEnterWorld_DrainsQueue_AndStripsLinkAnimations()
{
var loader = new FakeLoader();
uint linkAnim = 0x03000200u, cycleAnim = 0x03000201u;
loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
var sink = new RecordingSink();
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
var seq = new CSequence(loader);
// Build a link + cyclic tail directly (bypassing GetObjectSequence).
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f);
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f);
Assert.Equal(2, seq.Count);
var mgr = new MotionTableManager(null, state, seq, sink);
mgr.AddToQueue(WalkForward, 0);
mgr.HandleEnterWorld();
// Queue drained, MotionDone(false) fired.
Assert.Single(sink.Calls);
Assert.False(sink.Calls[0].Success);
Assert.Empty(mgr.PendingAnimations);
// Link animation stripped — only the cyclic tail remains.
Assert.Equal(1, seq.Count);
}
[Fact]
public void HandleEnterWorld_EmptyQueue_StillStripsLinkAnimations_NoOpOnDrain()
{
var loader = new FakeLoader();
uint linkAnim = 0x03000210u, cycleAnim = 0x03000211u;
loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
var sink = new RecordingSink();
var state = new MotionState();
var seq = new CSequence(loader);
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f);
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f);
var mgr = new MotionTableManager(null, state, seq, sink);
mgr.HandleEnterWorld();
Assert.Empty(sink.Calls);
Assert.Equal(1, seq.Count); // link stripped, cyclic tail remains
}
// ── truncate blocked/allowed matrices for BOTH class masks ──────────
[Fact]
public void RemoveRedundantLinks_CycleClassTail_EarlierSameMotion_NoBlocker_Truncates()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5); // earlier same-motion, non-zero
mgr.AddToQueue(WalkForward, 3); // new tail, same motion -> matches the
// earlier node immediately (no
// intervening node to block) ->
// truncate walks tail_..matched
// EXCLUSIVE, so the TAIL node itself
// (this new entry) is in the walked
// range and gets zeroed; the earlier
// matched node (the stop boundary)
// is untouched.
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched
Assert.Equal(0u, list[1].NumAnims); // the redundant re-issue itself, zeroed
}
[Fact]
public void RemoveRedundantLinks_CycleClassTail_WithInterveningZeroTickNode_Truncates()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5); // earlier match
mgr.AddToQueue(ThrustMed, 0); // intervening, ZERO ticks -> does not block (only non-zero blocks)
mgr.AddToQueue(WalkForward, 3); // new tail, same motion
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched
Assert.Equal(0u, list[1].NumAnims); // already zero (unaffected either way)
Assert.Equal(0u, list[2].NumAnims); // the new tail — ALSO in the truncation range
// (truncate walks tail..matched EXCLUSIVE, so
// the tail itself IS zeroed too)
}
[Fact]
public void RemoveRedundantLinks_CycleClassTail_InterveningDifferentCycle_NotBlocked_Truncates()
{
// Bit decomposition (verified): 0xb0000000 == STYLE(0x80000000) |
// MODIFIER(0x20000000) | ACTION(0x10000000) — it deliberately
// EXCLUDES the cycle-class bit (0x40000000) itself. So a DIFFERENT
// intervening CYCLE (RunForward) does NOT block a WalkForward
// cycle-tail scan; two cycles naturally supersede each other via the
// base-cycle rebuild mechanism.
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5); // earlier match candidate
mgr.AddToQueue(RunForward, 4); // intervening, non-zero, CYCLE-class but not in 0xb0000000 -> does not block
mgr.AddToQueue(WalkForward, 3); // new tail, same motion as the earlier node
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims); // matched (stop boundary), untouched
Assert.Equal(0u, list[1].NumAnims); // truncated — RunForward zeroed
Assert.Equal(0u, list[2].NumAnims); // truncated — the new tail itself zeroed
}
[Fact]
public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroModifierClassNode()
{
// 0xb0000000 DOES include the modifier-class bit (0x20000000) — a
// pure modifier-class node with non-zero ticks blocks a cycle-tail
// collapse (can't safely erase an active modifier's queue entry).
var (mgr, _, _, _) = MakeManager();
const uint pureModifier = 0x20000099u; // modifier-class only, no cycle/action/style bits
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0xb0000000 -> BLOCKS
mgr.AddToQueue(WalkForward, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
// Blocked -> nothing truncated, all NumAnims preserved as queued.
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(2u, list[1].NumAnims);
Assert.Equal(3u, list[2].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroActionClassNode()
{
// 0xb0000000 also includes the action-class bit (0x10000000).
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(ThrustMed, 2); // non-zero, action-class -> matches 0xb0000000 -> BLOCKS
mgr.AddToQueue(WalkForward, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(2u, list[1].NumAnims); // untouched — blocked
Assert.Equal(3u, list[2].NumAnims); // untouched — blocked
}
[Fact]
public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroStyleClassNode()
{
// 0xb0000000 also includes the style-class top bit (0x80000000).
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(HandCombatStyle, 2); // non-zero, style-class -> matches 0xb0000000 -> BLOCKS
mgr.AddToQueue(WalkForward, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(2u, list[1].NumAnims);
Assert.Equal(3u, list[2].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_StyleClassTail_EarlierSameMotion_Truncates()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(HandCombatStyle, 5); // style-class (top bit set), earlier match
mgr.AddToQueue(ThrustMed, 0); // zero-tick intervening, no block
mgr.AddToQueue(HandCombatStyle, 3); // new tail, same style motion
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(0u, list[1].NumAnims);
Assert.Equal(0u, list[2].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroModifierClassNode()
{
// Style-class tail scan blocks on the WIDER 0x70000000 mask
// (cycle|action|modifier) -- a pure modifier-class node DOES block
// here, unlike the cycle-class tail scan above.
var (mgr, _, _, _) = MakeManager();
const uint pureModifier = 0x20000099u;
mgr.AddToQueue(HandCombatStyle, 5);
mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0x70000000 -> BLOCKS
mgr.AddToQueue(HandCombatStyle, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(2u, list[1].NumAnims); // untouched — blocked
Assert.Equal(3u, list[2].NumAnims); // untouched — blocked
}
[Fact]
public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroCycleClassNode()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(HandCombatStyle, 5);
mgr.AddToQueue(WalkForward, 4); // non-zero, cycle-class -> matches 0x70000000 too -> BLOCKS
mgr.AddToQueue(HandCombatStyle, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(4u, list[1].NumAnims);
Assert.Equal(3u, list[2].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_NoEarlierMatch_ScanExhausts_NoTruncation()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(RunForward, 4);
mgr.AddToQueue(WalkForward, 3); // no earlier WalkForward anywhere
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(4u, list[0].NumAnims);
Assert.Equal(3u, list[1].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_TrailingZeroTickNodesSkipped_BeforeScanning()
{
// Tail itself is zero-tick -> skip backward past it to find the
// "effective tail" for the class dispatch.
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(WalkForward, 0); // trailing zero-tick — skipped as "effective tail" search
var list = mgr.PendingAnimations.ToList();
// Effective tail (after skip) = list[0] (WalkForward, 5) itself —
// scanning backward from it finds nothing earlier -> no truncation.
Assert.Equal(2, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(0u, list[1].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_AllZeroTickNodes_ScanBottomsOut_ReturnsQuietly()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 0);
mgr.AddToQueue(RunForward, 0);
// Must not throw; nothing to truncate.
mgr.RemoveRedundantLinks();
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(0u, list[0].NumAnims);
Assert.Equal(0u, list[1].NumAnims);
}
[Fact]
public void RemoveRedundantLinks_EmptyQueue_IsNoOp()
{
var (mgr, _, _, _) = MakeManager();
mgr.RemoveRedundantLinks(); // must not throw
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void RemoveRedundantLinks_ModifierOrActionClassTail_NoScanPerformed()
{
// A pure modifier-class or action-class tail motion takes NEITHER
// the cycle-tail nor the style-tail branch in retail -> no scan, no
// truncation, regardless of what precedes it.
var (mgr, _, _, _) = MakeManager();
const uint pureModifier = 0x20000099u;
mgr.AddToQueue(pureModifier, 5);
mgr.AddToQueue(pureModifier, 3); // same motion id repeated, but tail is modifier-class -> no truncation path
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(5u, list[0].NumAnims);
Assert.Equal(3u, list[1].NumAnims);
}
// ── truncate_animation_list: zeroes in place, nodes stay queued ─────
[Fact]
public void Truncate_ViaRedundantCollapse_NodesStayQueued_OnlyNumAnimsZeroed()
{
// RunForward is a DIFFERENT cycle-class motion — not in the
// 0xb0000000 cycle-tail block mask (which excludes the cycle bit) —
// so it does not block WalkForward's re-match scan and gets
// truncated (zeroed in place, still queued) along with the tail.
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(RunForward, 2); // will get zeroed but not removed
mgr.AddToQueue(WalkForward, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count); // node count UNCHANGED — truncate never unlinks
Assert.Equal(RunForward, list[1].Motion); // motion id preserved, only NumAnims zeroed
Assert.Equal(0u, list[1].NumAnims);
}
[Fact]
public void Truncate_StripsLinkAnimationsFromSequence_ByTotalRemovedTicks()
{
var loader = new FakeLoader();
uint link1 = 0x03000300u, link2 = 0x03000301u, cyc = 0x03000302u;
loader.Register(link1, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(link2, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(cyc, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity));
var sink = new RecordingSink();
var state = new MotionState();
var seq = new CSequence(loader);
// Build a 3-node sequence: link1, link2 (both before first_cyclic-ish),
// cyc (the cyclic tail — append_animation slides first_cyclic to it).
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link1, 30f), 1f);
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link2, 30f), 1f);
CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cyc, 30f), 1f);
Assert.Equal(3, seq.Count);
var mgr = new MotionTableManager(null, state, seq, sink);
mgr.AddToQueue(WalkForward, 2); // earlier match, non-zero
mgr.AddToQueue(WalkForward, 2); // new tail, same motion -> truncates the earlier
// one's successor range (which is just this
// node itself, ticks=2) -> RemoveLinkAnimations(2)
// RemoveLinkAnimations(2) removes up to 2 predecessors of first_cyclic
// (link2, then link1) from the live sequence.
Assert.Equal(1, seq.Count); // only the cyclic tail (cyc) remains
}
// ── rapid same-motion re-issue -> collapse (Fix B replacement proof) ──
[Fact]
public void RapidSameMotionReissue_ThroughAddToQueue_CollapsesRedundantMiddleNodes()
{
// Enqueue walk, run, walk, run quickly. Bit decomposition of the
// cycle-tail block mask (0xb0000000 = STYLE|MODIFIER|ACTION,
// deliberately EXCLUDING the cycle bit 0x40000000): a DIFFERENT
// real cycle in between does NOT block a cycle-tail scan, so:
// 1. add W(5) -> [W5]
// 2. add R(4): tail=R, scan W5 -> no match (diff motion), no
// block (W not in 0xb0000000) -> scan exhausts -> [W5,R4]
// 3. add W(3): tail=W, scan R4 -> no match, no block -> scan W5
// -> MATCH (same motion, non-zero) -> truncate(W5..tail
// exclusive) zeroes R4 and this new W3 -> [W5, R0, W0]
// 4. add R(2): tail=R, scan W0(NumAnims=0, fails the non-zero
// match requirement) -> no block (0 ticks) -> scan R0
// (NumAnims=0, fails non-zero match requirement too) -> no
// block -> scan W5 -> no match (diff motion), no block ->
// scan exhausts -> nothing new truncated -> [W5, R0, W0, R2]
// This is the Fix-B-replacement proof: the FIRST redundant
// walk->run->walk sequence collapses down to just the original
// WalkForward(5) plus the freshly-added RunForward(2) — matching
// "rapid re-issue of the same locomotion collapses" without
// inventing a bespoke locomotion-low-byte special case (H6).
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(RunForward, 4);
mgr.AddToQueue(WalkForward, 3);
mgr.AddToQueue(RunForward, 2);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(4, list.Count);
Assert.Equal(5u, list[0].NumAnims); // original WalkForward — the surviving anchor
Assert.Equal(0u, list[1].NumAnims); // RunForward — truncated by step 3's collapse
Assert.Equal(0u, list[2].NumAnims); // WalkForward (re-issue) — truncated by step 3's collapse
Assert.Equal(2u, list[3].NumAnims); // newest RunForward — survives (its own scan found no match)
}
[Fact]
public void RapidSameMotionReissue_ZeroTickIntervening_DoesCollapse()
{
// When the intervening entries are ZERO-tick (already-instant / a
// fast-path re-speed that queued 0 outTicks), the scan is NOT
// blocked (only non-zero nodes block) and the collapse proceeds —
// this IS the realistic Fix-B-replacement shape: rapid re-speeds of
// the SAME cycle produce zero-outTicks entries between the real
// queue events.
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(WalkForward, 0); // e.g. a fast-path re-speed, zero outTicks
mgr.AddToQueue(WalkForward, 3); // new tail, same motion
var list = mgr.PendingAnimations.ToList();
Assert.Equal(3, list.Count);
Assert.Equal(5u, list[0].NumAnims); // matched node preserved
Assert.Equal(0u, list[1].NumAnims); // already zero
Assert.Equal(0u, list[2].NumAnims); // truncated
}
// ── the 2026-05-03 golden: cyclic->cyclic walk->run, truncate NOT firing ──
[Fact]
public void Golden_CyclicToCyclicWalkThenRun_BothNodesQueued_TruncateNotFiring()
{
// Quoted golden (Fix B comment block, r2-port-plan.md H6): cyclic->
// cyclic transitions produce add_to_queue(0x45000005) [WalkForward]
// followed by add_to_queue(0x44000007) [RunForward], with truncate
// NOT firing (different motion ids -> remove_redundant_links' match
// test never succeeds).
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 3);
mgr.AddToQueue(RunForward, 4);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(2, list.Count);
Assert.Equal(WalkForward, list[0].Motion);
Assert.Equal(3u, list[0].NumAnims); // untouched — no truncation
Assert.Equal(RunForward, list[1].Motion);
Assert.Equal(4u, list[1].NumAnims); // untouched
}
// ── initialize_state ──────────────────────────────────────────────
[Fact]
public void InitializeState_QueuesReadySentinel_WithSetDefaultStateOutTicks()
{
var loader = new FakeLoader();
uint readyAnim = 0x03000400u;
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));
var cmt = new CMotionTable(mt);
var sink = new RecordingSink();
var state = new MotionState();
var seq = new CSequence(loader);
var mgr = new MotionTableManager(cmt, state, seq, sink);
mgr.InitializeState();
Assert.Equal(NonCombatStyle, state.Style);
Assert.Equal(Ready, state.Substate);
Assert.Single(mgr.PendingAnimations);
var node = mgr.PendingAnimations.First();
Assert.Equal(MotionTableManager.ReadySentinel, node.Motion);
// SetDefaultState outTicks = cyclic.num_anims - 1 = 1 - 1 = 0.
Assert.Equal(0u, node.NumAnims);
}
[Fact]
public void InitializeState_NoTableLoaded_QueuesSentinelWithZeroTicks_NoThrow()
{
var sink = new RecordingSink();
var state = new MotionState();
var seq = new CSequence(new NullLoader());
var mgr = new MotionTableManager(null, state, seq, sink);
mgr.InitializeState();
Assert.Single(mgr.PendingAnimations);
Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion);
Assert.Equal(0u, mgr.PendingAnimations.First().NumAnims);
}
// ── PerformMovement error-code matrix ────────────────────────────────
[Fact]
public void PerformMovement_NoTableLoaded_ReturnsError7()
{
var (mgr, _, _, _) = MakeManager(table: null);
uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f));
Assert.Equal(MotionTableManagerError.NoTable, result);
Assert.Equal(7u, result);
}
[Fact]
public void PerformMovement_InterpretedCommand_Success_QueuesMotionWithOutTicks_ReturnsZero()
{
var loader = new FakeLoader();
uint readyAnim = 0x03000500u, walkAnim = 0x03000501u, linkAnim = 0x03000502u;
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;
Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f));
Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f));
Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f));
var cmt = new CMotionTable(mt);
var sink = new RecordingSink();
var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f };
var seq = new CSequence(loader);
var mgr = new MotionTableManager(cmt, state, seq, sink);
uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f));
Assert.Equal(MotionTableManagerError.Success, result);
Assert.Equal(WalkForward, state.Substate);
Assert.Single(mgr.PendingAnimations);
Assert.Equal(WalkForward, mgr.PendingAnimations.First().Motion);
// outTicks = link(1) + cycle(1) - 1 = 1
Assert.Equal(1u, mgr.PendingAnimations.First().NumAnims);
}
[Fact]
public void PerformMovement_InterpretedCommand_Failure_ReturnsError0x43_NoQueue()
{
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
// No cycles registered at all -> DoObjectMotion fails.
var cmt = new CMotionTable(mt);
var (mgr, _, state, _) = MakeManager(cmt);
state.Style = NonCombatStyle;
state.Substate = Ready;
state.SubstateMod = 1f;
uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f));
Assert.Equal(MotionTableManagerError.MotionFailed, result);
Assert.Equal(0x43u, result);
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void PerformMovement_StopInterpretedCommand_Success_QueuesReadySentinel()
{
var loader = new FakeLoader();
uint runAnim = 0x03000510u, readyAnim = 0x03000511u, exitLinkAnim = 0x03000512u;
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 cmt = new CMotionTable(mt);
var sink = new RecordingSink();
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
var seq = new CSequence(loader);
var mgr = new MotionTableManager(cmt, state, seq, sink);
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(RunForward, 1f));
Assert.Equal(MotionTableManagerError.Success, result);
Assert.Equal(Ready, state.Substate); // re-driven to style default
Assert.Single(mgr.PendingAnimations);
Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion);
}
[Fact]
public void PerformMovement_StopInterpretedCommand_Failure_ReturnsError0x43_NoQueue()
{
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
var cmt = new CMotionTable(mt);
var (mgr, _, state, _) = MakeManager(cmt);
// No modifier and no matching current substate -> StopObjectMotion fails.
state.Style = NonCombatStyle;
state.Substate = Ready;
state.SubstateMod = 1f;
const uint unrelatedModifier = 0x20000099u;
uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(unrelatedModifier, 1f));
Assert.Equal(MotionTableManagerError.MotionFailed, result);
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void PerformMovement_StopCompletely_UnconditionallyQueuesReadySentinel_EvenOnNoOpStop()
{
// Even when StopObjectCompletely finds nothing to stop (returns
// false), PerformMovement STILL queues the Ready sentinel
// unconditionally (decomp §11 note) and returns success.
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
// No StyleDefaults entry -> the final StopSequenceMotion(substate=0,...)
// call inside StopObjectCompletely can't resolve a style default either,
// but that's fine: Case A requires substate==0x40000000-class AND ==
// state.Substate; with Substate=0 (default MotionState ctor) neither
// stop mechanism matches anything -> both return false -> overall false.
var cmt = new CMotionTable(mt);
var (mgr, _, state, _) = MakeManager(cmt);
// state.Substate stays 0 (default) -> StopObjectCompletely no-ops.
uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely());
Assert.Equal(MotionTableManagerError.Success, result);
Assert.Single(mgr.PendingAnimations);
Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion);
}
[Fact]
public void PerformMovement_StopCompletely_DrainsModifiers_QueuesReadySentinel()
{
var loader = new FakeLoader();
uint runAnim = 0x03000520u, readyAnim = 0x03000521u, exitLinkAnim = 0x03000522u;
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 cmt = new CMotionTable(mt);
var sink = new RecordingSink();
var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f };
var seq = new CSequence(loader);
var mgr = new MotionTableManager(cmt, state, seq, sink);
Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _));
uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely());
Assert.Equal(MotionTableManagerError.Success, result);
Assert.Equal(Ready, state.Substate);
Assert.Single(mgr.PendingAnimations);
Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion);
}
[Fact]
public void PerformMovement_UnhandledType_ReturnsNotHandled_NoQueue()
{
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
var cmt = new CMotionTable(mt);
var (mgr, _, _, _) = MakeManager(cmt);
uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.RawCommand, WalkForward, 1f));
Assert.Equal(MotionTableManagerError.NotHandled, result);
Assert.Empty(mgr.PendingAnimations);
}
[Fact]
public void PerformMovement_StopRawCommand_ReturnsNotHandled()
{
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle };
var cmt = new CMotionTable(mt);
var (mgr, _, _, _) = MakeManager(cmt);
uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.StopRawCommand, WalkForward, 1f));
Assert.Equal(MotionTableManagerError.NotHandled, result);
}
// ── AddToQueue calls RemoveRedundantLinks immediately (opportunistic) ──
[Fact]
public void AddToQueue_CallsRemoveRedundantLinks_Immediately_NotDeferred()
{
var (mgr, _, _, _) = MakeManager();
mgr.AddToQueue(WalkForward, 5);
mgr.AddToQueue(ThrustMed, 0);
// Adding a third same-motion WalkForward should IMMEDIATELY trigger
// the collapse as part of THIS AddToQueue call (not deferred to a
// later explicit RemoveRedundantLinks() call).
mgr.AddToQueue(WalkForward, 3);
var list = mgr.PendingAnimations.ToList();
Assert.Equal(0u, list[1].NumAnims); // already truncated by the time AddToQueue returns
Assert.Equal(0u, list[2].NumAnims);
}
}