feat(anim): route Commands[] list — full NPC/monster motion support
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.
Three parts:
1. New MotionItem wire record in ServerMotionState — carries Command
(u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.
2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
now read the full InterpretedMotionState: all 7 flag fields
(CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
MotionItem tail. The packed u32 encodes flags in low 7 bits and
command count in bits 7+ (see ACE InterpretedMotionState.cs:131).
3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
class byte from a 16-bit wire value via a reflection-built lookup
of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
(ACE InterpretedMotionState.cs:139) and we need the class to route:
- 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
PlayAction (resolves from Modifiers or Links dict, overlays on
current cycle)
- 0x40xxxxxx SubState → SetCycle (cycle change)
4. OnLiveMotionUpdated in GameWindow dispatches each command:
- SubState class (0x40xxx) → SetCycle (treated same as
ForwardCommand)
- Action/Modifier/ChatEmote → PlayAction — the link animation
plays once then drops back to the current cycle naturally
(matches retail's action-queue pattern in CMotionInterp
DoInterpretedMotion, decompile FUN_00528F70).
Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).
Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b7a9322b40
commit
3f41872d88
6 changed files with 341 additions and 11 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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