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:
Erik 2026-04-19 10:34:18 +02:00
parent b7a9322b40
commit 3f41872d88
6 changed files with 341 additions and 11 deletions

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
@ -122,17 +123,19 @@ public static class UpdateMotion
ushort? forwardCommand = null;
float? forwardSpeed = null;
List<CreateObject.MotionItem>? commands = null;
if (movementType == 0)
{
// InterpretedMotionState — same layout as in CreateObject's
// MovementInvalid branch, just reached via the header'd path.
// Only ForwardCommand is pulled out; the rest is deliberately
// ignored because the animation system consumes nothing else.
// Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand.
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
uint numCommands = packed >> 7;
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
// if present, matching the CreateObject parser's behavior.
@ -161,10 +164,30 @@ public static class UpdateMotion
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// SidestepSpeed (0x20) — skip
if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
// TurnSpeed (0x40) — skip
if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
// Commands list: actions/emotes/attacks. Guard against a
// malformed numCommands by capping at a sane max.
if (numCommands > 0 && numCommands < 1024)
{
commands = new List<CreateObject.MotionItem>((int)numCommands);
for (int i = 0; i < numCommands; i++)
{
if (body.Length - pos < 8) break;
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4));
pos += 8;
commands.Add(new CreateObject.MotionItem(cmd, seq, speed));
}
}
done:;
}
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed));
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands));
}
catch
{