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:
commit
862cd5662f
8 changed files with 1273 additions and 46 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue