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>
1018 lines
44 KiB
C#
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);
|
|
}
|
|
}
|