using System.Buffers.Binary; using System.Collections.Generic; namespace AcDream.Core.Net.Messages; /// /// Inbound CreateObject GameMessage (opcode 0xF745). This is /// the primary spawn-an-entity-into-my-world message — the server sends /// one for every visible weenie (players, creatures, items, scenery /// weenies like the Holtburg foundry statue) in the client's loaded area. /// /// /// acdream's parser extracts only the fields needed to hand the spawn off /// to IGameState: /// /// /// GUID — always at the start of the body (after the opcode). /// Position (landblock id + local XYZ + rotation quaternion) — present /// when is set in the physics /// description flags. We need this to place the entity in the world. /// SetupTableId — present when /// is set. This is the dat-id for the visual model /// (Setup/GfxObj chain) that acdream's existing /// SetupMesh + GfxObjMesh pipeline already knows how to render. /// /// /// /// Most other fields (extended weenie header, object description, motion tables, /// palettes, texture overrides, animation frames, velocity, ...) are /// consumed-but-ignored so the parse position ends up wherever the /// client-side caller wanted — a Parse call doesn't need to reach /// the end of the body to return useful output. We read through the fixed /// WeenieHeader prefix for Name/ItemType, then stop before optional header /// tails. /// /// /// /// Ported by reading ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs /// (SerializeCreateObject, SerializeModelData, SerializePhysicsData) plus /// ACE.Entity/Position.cs and PhysicsDescriptionFlag.cs. /// See NOTICE.md. /// /// public static class CreateObject { public const uint Opcode = 0xF745u; /// AC dat id type prefix for GfxObj (visual model) ids. public const uint GfxObjTypePrefix = 0x01000000u; /// Palette dat id type prefix. public const uint PaletteTypePrefix = 0x04000000u; /// SurfaceTexture dat id type prefix. public const uint SurfaceTextureTypePrefix = 0x05000000u; /// Icon dat id type prefix. public const uint IconTypePrefix = 0x06000000u; [Flags] public enum PhysicsDescriptionFlag : uint { None = 0x000000, CSetup = 0x000001, MTable = 0x000002, Velocity = 0x000004, Acceleration = 0x000008, Omega = 0x000010, Parent = 0x000020, Children = 0x000040, ObjScale = 0x000080, Friction = 0x000100, Elasticity = 0x000200, Timestamps = 0x000400, STable = 0x000800, PeTable = 0x001000, DefaultScript = 0x002000, DefaultScriptIntensity = 0x004000, Position = 0x008000, Movement = 0x010000, AnimationFrame = 0x020000, Translucency = 0x040000, } /// /// The spawn fields acdream currently cares about. Position and /// SetupTableId are nullable because their corresponding /// physics-description-flag bits may not be set on every CreateObject. /// public readonly record struct Parsed( uint Guid, ServerPosition? Position, uint? SetupTableId, IReadOnlyList AnimPartChanges, IReadOnlyList TextureChanges, IReadOnlyList SubPalettes, uint? BasePaletteId, float? ObjScale, string? Name, uint? ItemType, ServerMotionState? MotionState, uint? MotionTableId, ushort InstanceSequence = 0, ushort TeleportSequence = 0, ushort ServerControlSequence = 0, ushort ForcePositionSequence = 0); /// /// The relevant subset of the server-sent MovementData / /// InterpretedMotionState: the entity's current stance /// (MotionStance, e.g. NonCombat / HandCombat / Crouch) and its /// active ForwardCommand (MotionCommand, e.g. Ready / Crouch / /// AttackHigh). These are what we need to compose a MotionTable /// cycle key (stance << 16) | (command & 0xFFFFFF) and /// resolve the right idle frame for entities like the Foundry's /// Nullified Statue of a Drudge, which is rendered in the wrong pose /// if you only consult the MotionTable's default style. /// /// /// Full InterpretedMotionState from the server. Covers every field that /// can appear in the wire — the earlier version only tracked /// ForwardCommand/ForwardSpeed and silently discarded TurnCommand / /// SideStepCommand / their speeds. That made it impossible to render /// smooth circles or strafing for remote entities — the client literally /// had no rotation-intent data between UpdatePositions. /// /// /// Per ACE InterpretedMotionState.Write (line 127) the wire /// order is: CurrentStyle, ForwardCommand, SideStepCommand, /// TurnCommand (all ushort), then ForwardSpeed, SideStepSpeed, TurnSpeed /// (all float). Flag bits (MovementStateFlag enum): /// 0x01=CurrentStyle, 0x02=ForwardCommand, 0x04=ForwardSpeed, /// 0x08=SideStepCommand, 0x10=SideStepSpeed, 0x20=TurnCommand, /// 0x40=TurnSpeed. /// /// public readonly record struct ServerMotionState( ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null, IReadOnlyList? Commands = null, ushort? SideStepCommand = null, float? SideStepSpeed = null, ushort? TurnCommand = null, float? TurnSpeed = null, byte MovementType = 0, uint? MoveToParameters = null, float? MoveToSpeed = null, float? MoveToRunRate = null) { /// /// ACE/retail movement types 6 and 7 are server-controlled /// MoveToObject/MoveToPosition packets. Their union body does not /// carry an InterpretedMotionState.ForwardCommand, so command absence /// is not a stop signal. /// public bool IsServerControlledMoveTo => MovementType is 6 or 7; public bool MoveToCanRun => !MoveToParameters.HasValue || (MoveToParameters.Value & 0x2u) != 0; } /// /// 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 /// public readonly record struct MotionItem( ushort Command, ushort PackedSequence, float Speed); /// /// Server instruction to replace the surface texture at /// that currently uses /// with . /// Used to paint armor pieces the right color, make the statue /// look stone instead of flesh, etc. /// public readonly record struct TextureChange(byte PartIndex, uint OldTexture, uint NewTexture); /// /// Palette-range swap: overlay 's colors /// into the entity's base palette starting at index /// for colors. Used for skin/hair color /// on characters and team-color variations. Both Offset and Length /// are encoded as 8-bit values that the client historically multiplies /// by 8 to get the final palette index. /// public readonly record struct SubPaletteSwap(uint SubPaletteId, byte Offset, byte Length); /// A server-side position: landblock id + local XYZ + unit quaternion rotation. public readonly record struct ServerPosition( uint LandblockId, float PositionX, float PositionY, float PositionZ, float RotationW, float RotationX, float RotationY, float RotationZ); /// /// Server instruction to replace part index /// in the base Setup's part list with the mesh at . /// This is the primary mechanism ACE uses to dress characters (head → /// helmet, torso → chestplate, ...) and also how many specialized /// weenies like statues get their unique mesh overrides. /// public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId); /// /// Parse a reassembled CreateObject body. must /// start with the 4-byte opcode. Returns null if the body is /// malformed (truncated field); returns a populated /// on success. The parser stops at the end of PhysicsData; subsequent /// weenie-header fields are deliberately not consumed. /// public static Parsed? TryParse(ReadOnlySpan body) { // Accumulators declared at the top so PartialResult (local function // at the bottom) can reference them before they're conditionally // populated — C# rejects forward references otherwise. ServerPosition? position = null; uint? setupTableId = null; float? objScale = null; ServerMotionState? motionState = null; uint? motionTableId = null; try { int pos = 0; uint opcode = ReadU32(body, ref pos); if (opcode != Opcode) return null; uint guid = ReadU32(body, ref pos); // --- ModelData --- // Header: byte 0x11 marker, byte subPalettes, byte textureChanges, byte animPartChanges if (body.Length - pos < 4) return null; byte _marker = body[pos]; pos += 1; byte subPaletteCount = body[pos]; pos += 1; byte textureChangeCount = body[pos]; pos += 1; byte animPartChangeCount = body[pos]; pos += 1; uint? basePaletteId = null; if (subPaletteCount > 0) basePaletteId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix); var subPalettes = subPaletteCount == 0 ? (IReadOnlyList)Array.Empty() : new SubPaletteSwap[subPaletteCount]; for (int i = 0; i < subPaletteCount; i++) { uint subPalId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix); if (body.Length - pos < 2) return null; byte offset = body[pos]; pos += 1; byte length = body[pos]; pos += 1; ((SubPaletteSwap[])subPalettes)[i] = new SubPaletteSwap(subPalId, offset, length); } var textureChanges = textureChangeCount == 0 ? (IReadOnlyList)Array.Empty() : new TextureChange[textureChangeCount]; for (int i = 0; i < textureChangeCount; i++) { if (body.Length - pos < 1) return null; byte partIndex = body[pos]; pos += 1; uint oldTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix); uint newTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix); ((TextureChange[])textureChanges)[i] = new TextureChange(partIndex, oldTex, newTex); } // Extract AnimPartChanges — the server uses these to replace // base Setup parts with armored/statue/whatever-specific meshes. // Without decoding these, characters render "naked" and custom // weenies render as whatever their base Setup looks like. // // NOTE: ACE writes the NewModelId through WritePackedDwordOfKnownType // with knownType=0x01000000 (GfxObj type prefix). That writer STRIPS // the high-byte type if present before writing the PackedDword. We // have to OR it back on read or our GfxObj dat lookup will fail // (silently, producing no mesh refs — hence the Phase 4.7h regression). var animParts = animPartChangeCount == 0 ? (IReadOnlyList)Array.Empty() : new AnimPartChange[animPartChangeCount]; for (int i = 0; i < animPartChangeCount; i++) { if (body.Length - pos < 1) return null; byte partIndex = body[pos]; pos += 1; uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix); ((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId); } AlignTo4(ref pos); // --- PhysicsData --- if (body.Length - pos < 8) return null; var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; pos += 4; // PhysicsState (skip) if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0) { // u32 length, length bytes of serialized MovementData (no header // — see ACE WorldObject_Networking.cs:326 writer.Write(movementData, false)), // u32 isAutonomous (only present when the inner MovementData was non-empty). if (body.Length - pos < 4) return null; uint movementLen = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; if (movementLen > 0) { if (body.Length - pos < (int)movementLen) return null; int movementStart = pos; motionState = TryParseMovementData(body.Slice(movementStart, (int)movementLen)); pos = movementStart + (int)movementLen; if (body.Length - pos < 4) return null; pos += 4; // isAutonomous u32 } } else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0) { if (body.Length - pos < 4) return null; pos += 4; } if ((physicsFlags & PhysicsDescriptionFlag.Position) != 0) { if (body.Length - pos < 32) return null; position = new ServerPosition( LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 0)), PositionX: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)), PositionY: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)), PositionZ: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 12)), RotationW: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 16)), RotationX: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 20)), RotationY: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 24)), RotationZ: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 28))); pos += 32; } if ((physicsFlags & PhysicsDescriptionFlag.MTable) != 0) { if (body.Length - pos < 4) return null; motionTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; } if ((physicsFlags & PhysicsDescriptionFlag.STable) != 0) { if (body.Length - pos < 4) return null; pos += 4; } if ((physicsFlags & PhysicsDescriptionFlag.PeTable) != 0) { if (body.Length - pos < 4) return null; pos += 4; } if ((physicsFlags & PhysicsDescriptionFlag.CSetup) != 0) { if (body.Length - pos < 4) return null; setupTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; } // Skip the remaining PhysicsData fields. Each is gated by a flag // and must be consumed so we end up at the start of WeenieHeader. // Order matches ACE's SerializePhysicsData exactly. if ((physicsFlags & PhysicsDescriptionFlag.Parent) != 0) { if (body.Length - pos < 8) return null; pos += 8; // wielderId u32 + parentLocation u32 } if ((physicsFlags & PhysicsDescriptionFlag.Children) != 0) { if (body.Length - pos < 4) return null; int childCount = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(pos)); pos += 4; if (childCount < 0 || childCount > 1024) return PartialResult(); if (body.Length - pos < childCount * 8) return null; pos += childCount * 8; // each child = guid u32 + locationId u32 } if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0) { if (body.Length - pos < 4) return PartialResult(); objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3 if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12; if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0) pos += 12; if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0) pos += 4; if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4; // 9 sequence timestamps, always present at end of PhysicsData. // Indices per holtburger: 0=position, 4=teleport, 5=serverControl, // 6=forcePosition, 8=instance. if (body.Length - pos < 9 * 2) return PartialResult(); var seqSpan = body.Slice(pos, 9 * 2); ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2)); ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2)); ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2)); ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2)); pos += 9 * 2; AlignTo4(ref pos); // --- WeenieHeader: read the fixed prefix fields we need. --- // ACE WorldObject_Networking.SerializeCreateObject writes: // weenieFlags, Name, WeenieClassId(PackedDword), // IconId(PackedDwordOfKnownType 0x06000000), ItemType, // ObjectDescriptionFlags, align. string? name = null; uint? itemType = null; if (body.Length - pos >= 4) { pos += 4; // skip weenieFlags u32 try { name = ReadString16L(body, ref pos); _ = ReadPackedDword(body, ref pos); // WeenieClassId _ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); if (body.Length - pos >= 4) itemType = ReadU32(body, ref pos); if (body.Length - pos >= 4) _ = ReadU32(body, ref pos); // ObjectDescriptionFlags AlignTo4(ref pos); } catch { /* truncated name — partial result is still useful */ } } return new Parsed(guid, position, setupTableId, animParts, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). Parsed PartialResult() => new( guid, position, setupTableId, animParts, textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId); } catch { return null; } } private static uint ReadU32(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 4) throw new FormatException("truncated u32"); uint v = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); pos += 4; return v; } private static string ReadString16L(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 2) throw new FormatException("truncated String16L length"); ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit"); if (source.Length - pos < length) throw new FormatException("truncated String16L body"); // Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252). string result = System.Text.Encoding.GetEncoding(1252).GetString(source.Slice(pos, length)); pos += length; int recordSize = 2 + length; int padding = (4 - (recordSize & 3)) & 3; pos += padding; return result; } /// /// Read a PackedDword from the stream. Format: /// /// u16 first. If the top bit (0x8000) is clear, the u16 IS the value (0..0x7FFF). /// Otherwise, read another u16 and combine: the full 32-bit value /// is ((lowHalfTopBitStripped) << 16) | highHalfFromNextU16. /// /// Ported from ACE's Extensions.WritePackedDword: for values ≤ 32767, emitted as /// u16; for larger, emitted as (value << 16) | ((value >> 16) | 0x8000) /// written as a little-endian u32. The reader is the inverse — sees the high- /// bit marker in the first u16, then reads the second u16. /// private static uint ReadPackedDword(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 2) throw new FormatException("truncated PackedDword"); ushort first = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; if ((first & 0x8000) == 0) return first; // Extended form: first holds the HIGH 16 bits with top bit as marker, // next u16 holds the LOW 16 bits. Strip the marker bit from the high half. if (source.Length - pos < 2) throw new FormatException("truncated PackedDword ext"); ushort second = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); pos += 2; uint high = (uint)(first & 0x7FFF); return (high << 16) | second; } /// /// Read a PackedDword that was written via WritePackedDwordOfKnownType. /// That writer strips the prefix before /// packing if the value had it set, so the reader must OR it back in to /// recover the original dat id. The zero sentinel is preserved as-is /// (a 0 means "no value" and must not be turned into knownType). /// private static uint ReadPackedDwordOfKnownType(ReadOnlySpan source, ref int pos, uint knownType) { uint packed = ReadPackedDword(source, ref pos); return packed == 0 ? 0 : (packed | knownType); } private static void AlignTo4(ref int pos) { int padding = (4 - (pos & 3)) & 3; pos += padding; } /// /// Parse the inner MovementData bytes (no header form, as written /// by ACE's CreateObject path with writer.Write(movementData, false)). /// We extract the CurrentStyle stance and, when MovementType is /// Invalid (the typical case for stationary entities like the /// Foundry's drudge statue), the InterpretedMotionState.ForwardCommand /// motion command. Both are used by the renderer to compose a MotionTable /// cycle key and resolve the entity's actual idle pose. /// /// Layout — see ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write /// (header=false) and InterpretedMotionState.cs::Write: /// /// /// u8 movementType /// u8 motionFlags /// u16 currentStyle (MotionStance) /// For MovementType.Invalid (==0): InterpretedMotionState body /// /// Returns null on truncation; partial results are still returned with /// whatever fields parsed successfully. /// private static ServerMotionState? TryParseMovementData(ReadOnlySpan mv) { try { int p = 0; if (mv.Length < 4) return null; byte movementType = mv[p]; p += 1; byte _motionFlags = mv[p]; p += 1; ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; ushort? forwardCommand = null; float? forwardSpeed = null; ushort? sidestepCommand = null; float? sidestepSpeed = null; ushort? turnCommand = null; float? turnSpeed = null; uint? moveToParameters = null; float? moveToSpeed = null; float? moveToRunRate = null; List? commands = null; // 0 = Invalid is the only union variant we care about for static // entities. Walking/turning entities use the other variants but // their forward command lives in InterpretedMotionState too; // those are typed differently though, so be conservative. if (movementType == 0) { // InterpretedMotionState: u32 (flags | numCommands<<7), then // 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; // Flag-bit + write order per ACE // InterpretedMotionState.Write @ line 127 // (MovementStateFlag enum @ ACE.Entity.Enum): // CurrentStyle = 0x01 (ushort) // ForwardCommand = 0x02 (ushort) // SideStepCommand = 0x08 (ushort) // TurnCommand = 0x20 (ushort) // ForwardSpeed = 0x04 (float) // SideStepSpeed = 0x10 (float) // TurnSpeed = 0x40 (float) // Note the bit values are NOT in write order — commands // come first in the wire stream regardless of bit value, // then speeds. Earlier versions had this mapping wrong, // which caused ForwardSpeed to silently never be read // (appeared as HasValue=False on every remote broadcast). if ((flags & 0x1u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } if ((flags & 0x2u) != 0) { if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } // SideStepCommand (bit 0x8, ushort) if ((flags & 0x8u) != 0) { if (mv.Length - p < 2) goto done; sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } // TurnCommand (bit 0x20, ushort) if ((flags & 0x20u) != 0) { if (mv.Length - p < 2) goto done; turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); p += 2; } // ForwardSpeed (bit 0x4, float) if ((flags & 0x4u) != 0) { if (mv.Length - p < 4) goto done; forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); p += 4; } // SideStepSpeed (bit 0x10, float) if ((flags & 0x10u) != 0) { if (mv.Length - p < 4) goto done; sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); p += 4; } // TurnSpeed (bit 0x40, float) if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p)); 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((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:; } else if (movementType is 6 or 7) { TryParseMoveToPayload( mv, p, movementType, out moveToParameters, out moveToSpeed, out moveToRunRate); } return new ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, sidestepCommand, sidestepSpeed, turnCommand, turnSpeed, movementType, moveToParameters, moveToSpeed, moveToRunRate); } catch { return null; } } private static bool TryParseMoveToPayload( ReadOnlySpan body, int pos, byte movementType, out uint? movementParameters, out float? speed, out float? runRate) { movementParameters = null; speed = null; runRate = null; if (movementType == 6) { if (body.Length - pos < 4) return false; pos += 4; // target guid } if (body.Length - pos < 16 + 28 + 4) return false; pos += 16; // Origin movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; pos += 4; // distanceToObject pos += 4; // minDistance pos += 4; // failDistance speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; pos += 4; // walkRunThreshold pos += 4; // desiredHeading runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); return true; } }