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; } } }