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
|
|
@ -1,4 +1,5 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
|
|
@ -109,7 +110,27 @@ public static class CreateObject
|
|||
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
|
||||
/// if you only consult the MotionTable's default style.
|
||||
/// </summary>
|
||||
public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null);
|
||||
public readonly record struct ServerMotionState(
|
||||
ushort Stance,
|
||||
ushort? ForwardCommand,
|
||||
float? ForwardSpeed = null,
|
||||
IReadOnlyList<MotionItem>? Commands = null);
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||
/// The server packs 0..many of these per broadcast: emotes, attacks,
|
||||
/// and other one-shot motions arrive here, not in ForwardCommand.
|
||||
///
|
||||
/// Wire layout (see ACE Network/Motion/MotionItem.cs):
|
||||
/// u16 command — low 16 bits of MotionCommand (Action class
|
||||
/// typically 0x10xx; ChatEmote 0x13xx)
|
||||
/// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp
|
||||
/// f32 speed — speedMod for the animation
|
||||
/// </summary>
|
||||
public readonly record struct MotionItem(
|
||||
ushort Command,
|
||||
ushort PackedSequence,
|
||||
float Speed);
|
||||
|
||||
/// <summary>
|
||||
/// Server instruction to replace the surface texture at
|
||||
|
|
@ -480,6 +501,7 @@ public static class CreateObject
|
|||
|
||||
ushort? forwardCommand = null;
|
||||
float? forwardSpeed = null;
|
||||
List<MotionItem>? commands = null;
|
||||
|
||||
// 0 = Invalid is the only union variant we care about for static
|
||||
// entities. Walking/turning entities use the other variants but
|
||||
|
|
@ -488,21 +510,20 @@ public static class CreateObject
|
|||
if (movementType == 0)
|
||||
{
|
||||
// InterpretedMotionState: u32 (flags | numCommands<<7), then
|
||||
// each present field in flag order. We only care about
|
||||
// ForwardCommand, so read in order and stop early if we
|
||||
// can't get that far.
|
||||
// each present field in flag order. Flag bits (low 7) are
|
||||
// CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is
|
||||
// the MotionItem list length that follows after the speed
|
||||
// fields (see ACE InterpretedMotionState.cs::Write).
|
||||
if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null);
|
||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p));
|
||||
p += 4;
|
||||
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
|
||||
uint numCommands = packed >> 7;
|
||||
|
||||
// CurrentStyle (0x1)
|
||||
if ((flags & 0x1u) != 0)
|
||||
{
|
||||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||||
// The InterpretedMotionState's CurrentStyle is just a copy
|
||||
// of MovementData.CurrentStyle per ACE source. Read and
|
||||
// prefer it as the more specific value.
|
||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||
p += 2;
|
||||
}
|
||||
|
|
@ -525,10 +546,32 @@ public static class CreateObject
|
|||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||||
p += 4;
|
||||
}
|
||||
// SidestepSpeed (0x20) — skip
|
||||
if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
||||
// TurnSpeed (0x40) — skip
|
||||
if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
||||
|
||||
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
|
||||
// u16 packedSeq + f32 speed). One-shot actions, emotes,
|
||||
// attacks — everything that's NOT a looping cycle change
|
||||
// arrives here. Cap read at the buffer boundary.
|
||||
if (numCommands > 0 && numCommands < 1024)
|
||||
{
|
||||
commands = new List<MotionItem>((int)numCommands);
|
||||
for (int i = 0; i < numCommands; i++)
|
||||
{
|
||||
if (mv.Length - p < 8) break;
|
||||
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2));
|
||||
float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4));
|
||||
p += 8;
|
||||
commands.Add(new MotionItem(cmd, seq, speed));
|
||||
}
|
||||
}
|
||||
done:;
|
||||
}
|
||||
|
||||
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed);
|
||||
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue