feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event

Server sends UpdateMotion whenever an entity's motion state changes:
NPCs starting a walk cycle, creatures switching to a combat stance,
doors opening, a player waving, etc. Phase 6.1-6.4 already handles
rendering different (stance, forward-command) pairs for the INITIAL
CreateObject, but without this message NPCs freeze in whatever pose
they spawned with and never transition to walking/fighting.

Added UpdateMotion.TryParse with the same ServerMotionState the
CreateObject path uses, reached via a slightly different outer
layout (guid + instance seq + header'd MovementData; the MovementData
starts with the 8-byte sequence/autonomous header this time rather
than being preceded by a length field). Only the (stance, forward-
command) pair is extracted — same subset CreateObject grabs.

WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C
body parses successfully. The App-side wiring (guid→entity lookup
and AnimatedEntity cycle swap) is intentionally deferred to a
separate commit because it touches GameWindow which is currently
being edited by the Phase 9.1 translucent-pass work.

89 Core.Net tests (was 83, +6 for UpdateMotion coverage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 20:33:26 +02:00
parent 4752b8a528
commit a71db90310
12 changed files with 675 additions and 45 deletions

View file

@ -0,0 +1,142 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>UpdateMotion</c> GameMessage (opcode <c>0xF74C</c>). The server
/// sends this whenever an already-spawned entity changes its motion state —
/// NPCs starting a walk cycle, creatures switching to attack stance, doors
/// opening, a player waving, etc. acdream's animation system needs to
/// consume these so the motion tick can switch the entity's cycle to the
/// new (stance, forward-command) pair instead of sitting on whatever the
/// initial CreateObject said.
///
/// <para>
/// Wire layout (see
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs</c>
/// and <c>references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write</c>
/// with <c>header = true</c>):
/// </para>
/// <list type="bullet">
/// <item><b>u32 opcode</b> — 0xF74C</item>
/// <item><b>u32 objectGuid</b> — which entity this update is for</item>
/// <item><b>u16 instanceSequence</b> — Sequences.ObjectInstance, tracked but not used for pose</item>
/// <item><b>MovementData with header</b>:
/// <list type="bullet">
/// <item>u16 movementSequence</item>
/// <item>u16 serverControlSequence</item>
/// <item>u8 isAutonomous, then align to 4 bytes</item>
/// <item>u8 movementType</item>
/// <item>u8 motionFlags</item>
/// <item>u16 currentStyle (MotionStance)</item>
/// <item>InterpretedMotionState when movementType == Invalid (0):
/// u32 flagsAndCommandCount, then each present field in flag order
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
/// TurnCommand u16, forward speed f32, sidestep speed f32,
/// turn speed f32), commands list, align.</item>
/// </list>
/// </item>
/// </list>
///
/// <para>
/// We only extract the two fields the animation system actually consumes:
/// the current <c>Stance</c> and the <c>ForwardCommand</c>. Everything else
/// is skipped. The outer message doesn't carry a length for MovementData,
/// so our parser reads exactly as far as it needs and leaves subsequent
/// bytes untouched.
/// </para>
/// </summary>
public static class UpdateMotion
{
public const uint Opcode = 0xF74Cu;
/// <summary>
/// Extracted payload: the guid of the entity whose motion changed and
/// the (stance, forward-command) pair describing its new pose. The
/// command is nullable because the <c>ForwardCommand</c> flag may be
/// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance").
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState);
/// <summary>
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
/// start with the 4-byte opcode. Returns null on malformed input
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
try
{
int pos = 0;
if (body.Length - pos < 4) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
if (opcode != Opcode) return null;
if (body.Length - pos < 4) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
// ObjectInstance sequence (u16) — tracked but not used for pose.
if (body.Length - pos < 2) return null;
pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence,
// u8 isAutonomous, then align to 4. The header bytes total 6 (2+2+1+1 pad),
// because 2+2+1 = 5, and aligning to 4 from offset 5 needs 3 pad bytes...
// Actually, ACE's writer.Align() pads the CURRENT BaseStream position
// after writing the byte, so after u16 + u16 + u8 we're at 5 bytes into
// MovementData; alignment rounds up to 8. So the header slot is 8 bytes.
if (body.Length - pos < 8) return null;
pos += 8;
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
byte movementType = body[pos]; pos += 1;
byte _motionFlags = body[pos]; pos += 1;
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
ushort? forwardCommand = 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.
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;
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
// if present, matching the CreateObject parser's behavior.
if ((flags & 0x1u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
// ForwardCommand (0x2)
if ((flags & 0x2u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
}
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand));
}
catch
{
return null;
}
}
}