using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
///
/// Inbound UpdateMotion GameMessage (opcode 0xF74C). 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.
///
///
/// Wire layout (see
/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs
/// and references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write
/// with header = true):
///
///
/// - u32 opcode — 0xF74C
/// - u32 objectGuid — which entity this update is for
/// - u16 instanceSequence — Sequences.ObjectInstance, tracked but not used for pose
/// - MovementData with header:
///
/// - u16 movementSequence
/// - u16 serverControlSequence
/// - u8 isAutonomous, then align to 4 bytes
/// - u8 movementType
/// - u8 motionFlags
/// - u16 currentStyle (MotionStance)
/// - 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.
///
///
///
///
///
/// We only extract the two fields the animation system actually consumes:
/// the current Stance and the ForwardCommand. 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.
///
///
public static class UpdateMotion
{
public const uint Opcode = 0xF74Cu;
///
/// 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 ForwardCommand flag may be
/// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance").
///
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState);
///
/// Parse a reassembled UpdateMotion body. must
/// start with the 4-byte opcode. Returns null on malformed input
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
///
public static Parsed? TryParse(ReadOnlySpan 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().
//
// ACE's Align() (Network/Extensions.cs:55) uses
// CalculatePadMultiple(BaseStream.Length, 4) — i.e. it pads based on
// the ABSOLUTE stream length, not a relative offset within the
// MovementData block.
//
// At this point the absolute stream has: opcode (4) + guid (4) +
// objectInstance (2) + movSeq (2) + srvSeq (2) + isAut (1) = 15.
// Align(4) rounds 15 → 16, so ONE pad byte is written.
// MovementData header = 2+2+1+1 = 6 bytes.
//
// Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword).
if (body.Length - pos < 6) return null;
pos += 6;
// 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;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
int preHex = Math.Min(body.Length, 32);
var hex = new System.Text.StringBuilder();
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
System.Console.WriteLine(
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
}
ushort? forwardCommand = null;
float? forwardSpeed = null;
ushort? sidestepCommand = null;
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
List? commands = null;
if (movementType == 0)
{
// InterpretedMotionState — same layout as in CreateObject's
// MovementInvalid branch, just reached via the header'd path.
// 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;
// Flag-bit layout + write order (ACE
// InterpretedMotionState.Write @ line 127 + MovementStateFlag
// enum — note the bit values are NOT in write order):
// CurrentStyle = 0x01 written first (ushort)
// ForwardCommand = 0x02 written second (ushort)
// SideStepCommand = 0x08 written third (ushort)
// TurnCommand = 0x20 written fourth (ushort)
// ForwardSpeed = 0x04 written fifth (float)
// SideStepSpeed = 0x10 written sixth (float)
// TurnSpeed = 0x40 written seventh (float)
// Our earlier version had the bit-to-field mapping wrong
// (treated Side/Turn commands as floats and ForwardSpeed as
// the wrong bit) — that's why every remote's ForwardSpeed
// was reading as "absent" (HasValue=False).
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;
}
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;
}
// SideStepCommand — ushort, bit 0x8
if ((flags & 0x8u) != 0)
{
if (body.Length - pos < 2) goto done;
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
// TurnCommand — ushort, bit 0x20
if ((flags & 0x20u) != 0)
{
if (body.Length - pos < 2) goto done;
turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
// ForwardSpeed — float, bit 0x4
if ((flags & 0x4u) != 0)
{
if (body.Length - pos < 4) goto done;
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// SideStepSpeed — float, bit 0x10
if ((flags & 0x10u) != 0)
{
if (body.Length - pos < 4) goto done;
sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// TurnSpeed — float, bit 0x40
if ((flags & 0x40u) != 0)
{
if (body.Length - pos < 4) goto done;
turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
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((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, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
}
catch
{
return null;
}
}
}