merge: animation overhaul branch (Opus agent, 10 commits, +32 tests)

Resolves remote-chars-lagging-forward, no-anim-speed-scaling, and
monster/NPC Commands-list (waves/attacks/deaths) not animating.
Adds dead-reckoning + sequence-wide velocity/omega + Commands[]
list parsing + MotionCommandResolver + soft-snap residual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:50:47 +02:00
commit 862cd5662f
8 changed files with 1273 additions and 46 deletions

View file

@ -110,6 +110,74 @@ public class UpdateMotionTests
Assert.Null(result.Value.MotionState.ForwardCommand);
}
[Fact]
public void ParsesForwardSpeed_WhenSpeedFlagSet()
{
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13)
// Test value: 1.5× speed — matches a typical RunRate broadcast.
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header
body[p++] = 0;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesCommandsList_Wave()
{
// A typical NPC wave broadcast:
// - stance NonCombat (0x003D)
// - ForwardCommand flag set, command = 0x0003 (Ready)
// - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 }
//
// Packed u32 = (flags | numCommands << 7)
// flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03
// numCommands << 7 = 1 << 7 = 0x80
// total = 0x83
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 0;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready
// MotionItem: u16 command + u16 packedSeq + f32 speed
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand);
Assert.NotNull(result.Value.MotionState.Commands);
Assert.Single(result.Value.MotionState.Commands!);
var wave = result.Value.MotionState.Commands![0];
Assert.Equal((ushort)0x0087, wave.Command);
Assert.Equal(1.0f, wave.Speed);
}
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{

View file

@ -979,6 +979,452 @@ public sealed class AnimationSequencerTests
Assert.Contains(hooks, h => h is AnimationDoneHook);
}
// ── MultiplyCyclicFramerate / speed-mod tracking ─────────────────────────
[Fact]
public void MultiplyCyclicFramerate_DoublesPlaybackRate()
{
// A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback
// rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames).
const uint Style = 0x003Du;
const uint Motion = 0x0007u; // RunForward
const uint AnimId = 0x03000401u;
// Unique per-frame Z so we can tell where the cursor lands.
var anim = new Animation();
for (int f = 0; f < 10; f++)
{
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = 9,
Framerate = 10f,
});
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
// Halve the playback rate.
seq.MultiplyCyclicFramerate(0.5f);
// 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5.
seq.Advance(1.0f);
var frames = seq.Advance(0.001f);
Assert.Single(frames);
Assert.InRange(frames[0].Origin.Z, 4f, 6f);
// Velocity also scales: originally (0,4,0), now (0,2,0).
Assert.Equal(2f, seq.CurrentVelocity.Y, 1);
}
[Fact]
public void MultiplyCyclicFramerate_PreservesCursorPosition()
{
// Changing speed mid-cycle must NOT reset the frame cursor — the
// animation keeps playing from where it was, just faster/slower.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000402u;
var anim = new Animation();
for (int f = 0; f < 10; f++)
{
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.Advance(0.3f); // cursor ~ frame 3
double before = GetFramePosition(seq);
seq.MultiplyCyclicFramerate(2.0f);
double after = GetFramePosition(seq);
Assert.Equal(before, after, 5);
}
[Fact]
public void SetCycle_SameMotionDifferentSpeed_RescalesInPlace()
{
// Re-issuing SetCycle with the same motion but a new speedMod must
// NOT reset the cursor — it should call MultiplyCyclicFramerate to
// keep the run loop smooth (retail behavior for a mid-run RunRate
// broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000403u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
seq.Advance(0.3f);
double cursorMid = GetFramePosition(seq);
Assert.Equal(1f, seq.CurrentSpeedMod, 3);
// Re-issue with 2× speed — should rescale in place.
seq.SetCycle(Style, Motion, speedMod: 2f);
Assert.Equal(2f, seq.CurrentSpeedMod, 3);
Assert.Equal(cursorMid, GetFramePosition(seq), 5);
}
[Fact]
public void CurrentVelocity_ScalesWithSpeedMod()
{
// A RunForward motion with MotionData.Velocity = (0,4,0) should
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
// 0.5×. The dead-reckoning integrator in TickAnimations reads
// CurrentVelocity each tick, so this has to be accurate.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000405u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1,
Framerate = 10f,
});
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
Assert.Equal(4f, seq.CurrentVelocity.Y, 3);
// Start a fresh sequencer so the initial SetCycle applies speedMod.
var seq2 = new AnimationSequencer(setup, mt, loader);
seq2.SetCycle(Style, Motion, speedMod: 1.5f);
Assert.Equal(6f, seq2.CurrentVelocity.Y, 3);
// Same-motion rescale path also updates velocity.
seq2.SetCycle(Style, Motion, speedMod: 0.5f);
Assert.Equal(2f, seq2.CurrentVelocity.Y, 2);
}
[Fact]
public void SetCycle_SameMotionSameSpeed_StaysNoOp()
{
// Guard: the new speed-path must not break the classic
// "identical call = no state change" behavior.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000404u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1.5f);
seq.Advance(0.2f);
double before = GetFramePosition(seq);
seq.SetCycle(Style, Motion, speedMod: 1.5f);
Assert.Equal(before, GetFramePosition(seq), 5);
Assert.Equal(1.5f, seq.CurrentSpeedMod, 3);
}
[Fact]
public void CurrentOmega_ReflectsMotionDataOmega()
{
// A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw)
// should surface as CurrentOmega = (0, 0, 1) after SetCycle.
// Scales with speedMod exactly like Velocity.
const uint Style = 0x003Du;
const uint Motion = 0x000Du; // TurnRight
const uint AnimId = 0x03000701u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 2f);
// Omega scales by speedMod — 1.0 × 2 = 2 rad/sec.
Assert.Equal(2.0f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void CurrentVelocity_PersistsThroughLinkTransition()
{
// Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity):
// sequence.Velocity is REPLACED by the most-recent MotionData's
// velocity. When SetCycle enqueues [link][cycle], after the final
// add_motion the velocity is the cycle's velocity — ALREADY.
// So even while the link animation plays visually, dead-reckoning
// reads the cycle's run-speed and moves the entity smoothly.
// Crucial: otherwise remote entities would stutter at every stance
// transition while the link plays.
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000601u;
const uint LinkAnim = 0x03000602u;
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion;
int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
QualifiedDataId<Animation> cycleQid = CycleAnim;
cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
mt.Cycles[cycleKey] = cycleMd;
// Link from idle → walk. Link MotionData has no velocity (typical).
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var linkCmdData = new MotionCommandData();
var linkMd = new MotionData(); // no HasVelocity flag
QualifiedDataId<Animation> linkQid = LinkAnim;
linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
linkCmdData.MotionData[(int)WalkMotion] = linkMd;
mt.Links[linkOuter] = linkCmdData;
var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion);
seq.SetCycle(Style, WalkMotion);
// We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
// the link, but CurrentVelocity reflects the most recent
// SetVelocity call — the cycle's. So velocity is 3.12 even before
// the link plays out.
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
// Advance past the link frames (2 frames at 10fps = 0.2s).
seq.Advance(0.25f);
// Still 3.12 — cycle is now current.
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
}
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
[Fact]
public void PlayAction_Action_ResolvesFromLinksDict()
{
// An Action-class command (mask 0x10) resolves via the Links dict
// keyed by (style, currentSubstate) → motion. Example: a ThrustMed
// attack while in SwordCombat stance.
const uint Style = 0x003Eu; // SwordCombat
const uint IdleMotion = 0x41000003u; // Ready
const uint ActionMotion = 0x10000058u; // ThrustMed (Action class)
const uint IdleAnimId = 0x03000501u;
const uint ActionAnimId= 0x03000502u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
// Action anim: distinct non-zero origin so we can detect it played.
var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Link: (SwordCombat, Ready) → ThrustMed
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(ActionAnimId, actionAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.01f); // burn the first idle frame
// Fire the action.
seq.PlayAction(ActionMotion);
// After a small advance, we should be reading the action anim (origin X=99).
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{
// A Modifier-class command (mask 0x20) — like Jump (0x2500003B) —
// resolves from the Modifiers dict, first with style-specific key
// then with unstyled fallback. Empirically: the modifier's anim
// plays on top of the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint JumpMotion = 0x2500003Bu; // Modifier class
const uint IdleAnimId = 0x03000510u;
const uint JumpAnimId = 0x03000511u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Modifier: (Style, Jump)
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(JumpAnimId, jumpAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(JumpMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(77f, fr[0].Origin.Z, 1);
}
[Fact]
public void PlayAction_Emote_RoutesThroughActionBranch()
{
// ChatEmotes like Wave (0x13000087) have class byte 0x13 =
// Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the
// Action bit is set, they route through the Links-dict lookup just
// like attacks. Verifies the class-bit math.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint WaveMotion = 0x13000087u;
const uint IdleAnimId = 0x03000520u;
const uint WaveAnimId = 0x03000521u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Register Links[(style, Ready)][Wave] = wave anim.
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(WaveAnimId, waveAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(WaveMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(55f, fr[0].Origin.Y, 1);
}
[Fact]
public void PlayAction_NoEntryInTable_IsNoOp()
{
// If neither Links nor Modifiers has the motion, PlayAction should
// silently return without disturbing the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint IdleAnimId = 0x03000530u;
const uint UnknownAction = 0x10001234u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.05f);
int queueBefore = seq.QueueCount;
seq.PlayAction(UnknownAction); // unknown motion → no-op
Assert.Equal(queueBefore, seq.QueueCount);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>

View file

@ -0,0 +1,53 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13,
/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer
/// routes commands to the wrong MotionTable dict and NPC emotes/attacks
/// silently fail.
/// </summary>
public class MotionCommandResolverTests
{
[Theory]
// SubState / Ready / Movement commands
[InlineData(0x0003, 0x41000003u)] // Ready
[InlineData(0x0005, 0x45000005u)] // WalkForward
[InlineData(0x0007, 0x44000007u)] // RunForward
[InlineData(0x0006, 0x45000006u)] // WalkBackward
[InlineData(0x000D, 0x6500000Du)] // TurnRight
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
[InlineData(0x0015, 0x40000015u)] // Falling
// Action-class one-shots: melee attacks, death, portals
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
[InlineData(0x0058, 0x10000058u)] // ThrustMed
[InlineData(0x005B, 0x1000005Bu)] // SlashHigh
[InlineData(0x0061, 0x10000061u)] // Shoot
[InlineData(0x004B, 0x1000004Bu)] // Jumpup
[InlineData(0x0050, 0x10000050u)] // FallDown
// ChatEmotes (class 0x13)
[InlineData(0x0087, 0x13000087u)] // Wave
[InlineData(0x0080, 0x13000080u)] // Laugh
[InlineData(0x007D, 0x1300007Du)] // BowDeep
public void ReconstructsKnownCommands(ushort wire, uint expected)
{
uint got = MotionCommandResolver.ReconstructFullCommand(wire);
Assert.Equal(expected, got);
}
[Fact]
public void ZeroWireReturnsZero()
{
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0));
}
[Fact]
public void UnknownWireReturnsZero()
{
// 0xFFFF is not a real MotionCommand low-16.
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF));
}
}