acdream/src/AcDream.Core.Net/Messages/CreateObject.cs
Erik 3f41872d88 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>
2026-04-19 10:34:18 +02:00

581 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>CreateObject</c> GameMessage (opcode <c>0xF745</c>). 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.
///
/// <para>
/// acdream's parser extracts only the fields needed to hand the spawn off
/// to <c>IGameState</c>:
/// </para>
/// <list type="bullet">
/// <item><b>GUID</b> — always at the start of the body (after the opcode).</item>
/// <item><b>Position</b> (landblock id + local XYZ + rotation quaternion) — present
/// when <see cref="PhysicsDescriptionFlag.Position"/> is set in the physics
/// description flags. We need this to place the entity in the world.</item>
/// <item><b>SetupTableId</b> — present when <see cref="PhysicsDescriptionFlag.CSetup"/>
/// is set. This is the dat-id for the visual model
/// (<c>Setup</c>/<c>GfxObj</c> chain) that acdream's existing
/// SetupMesh + GfxObjMesh pipeline already knows how to render.</item>
/// </list>
///
/// <para>
/// All other fields (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 <c>Parse</c> call doesn't need to reach
/// the end of the body to return useful output. We stop after PhysicsData
/// since that's the last segment containing fields acdream cares about
/// in this phase.
/// </para>
///
/// <para>
/// Ported by reading <c>ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs</c>
/// (SerializeCreateObject, SerializeModelData, SerializePhysicsData) plus
/// <c>ACE.Entity/Position.cs</c> and <c>PhysicsDescriptionFlag.cs</c>.
/// See NOTICE.md.
/// </para>
/// </summary>
public static class CreateObject
{
public const uint Opcode = 0xF745u;
/// <summary>AC dat id type prefix for GfxObj (visual model) ids.</summary>
public const uint GfxObjTypePrefix = 0x01000000u;
/// <summary>Palette dat id type prefix.</summary>
public const uint PaletteTypePrefix = 0x04000000u;
/// <summary>SurfaceTexture dat id type prefix.</summary>
public const uint SurfaceTextureTypePrefix = 0x05000000u;
[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,
}
/// <summary>
/// The three fields acdream cares about. Position and SetupTableId are
/// nullable because their corresponding physics-description-flag bits
/// may not be set on every CreateObject.
/// </summary>
public readonly record struct Parsed(
uint Guid,
ServerPosition? Position,
uint? SetupTableId,
IReadOnlyList<AnimPartChange> AnimPartChanges,
IReadOnlyList<TextureChange> TextureChanges,
IReadOnlyList<SubPaletteSwap> SubPalettes,
uint? BasePaletteId,
float? ObjScale,
string? Name,
ServerMotionState? MotionState,
uint? MotionTableId,
ushort InstanceSequence = 0,
ushort TeleportSequence = 0,
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
/// <c>InterpretedMotionState</c>: the entity's current stance
/// (MotionStance, e.g. NonCombat / HandCombat / Crouch) and its
/// active <c>ForwardCommand</c> (MotionCommand, e.g. Ready / Crouch /
/// AttackHigh). These are what we need to compose a MotionTable
/// cycle key <c>(stance &lt;&lt; 16) | (command &amp; 0xFFFFFF)</c> 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.
/// </summary>
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
/// <paramref name="PartIndex"/> that currently uses
/// <paramref name="OldTexture"/> with <paramref name="NewTexture"/>.
/// Used to paint armor pieces the right color, make the statue
/// look stone instead of flesh, etc.
/// </summary>
public readonly record struct TextureChange(byte PartIndex, uint OldTexture, uint NewTexture);
/// <summary>
/// Palette-range swap: overlay <paramref name="SubPaletteId"/>'s colors
/// into the entity's base palette starting at index <paramref name="Offset"/>
/// for <paramref name="Length"/> 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.
/// </summary>
public readonly record struct SubPaletteSwap(uint SubPaletteId, byte Offset, byte Length);
/// <summary>A server-side position: landblock id + local XYZ + unit quaternion rotation.</summary>
public readonly record struct ServerPosition(
uint LandblockId,
float PositionX, float PositionY, float PositionZ,
float RotationW, float RotationX, float RotationY, float RotationZ);
/// <summary>
/// Server instruction to replace part index <paramref name="PartIndex"/>
/// in the base Setup's part list with the mesh at <paramref name="NewModelId"/>.
/// 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.
/// </summary>
public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId);
/// <summary>
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
/// malformed (truncated field); returns a populated <see cref="Parsed"/>
/// on success. The parser stops at the end of PhysicsData; subsequent
/// weenie-header fields are deliberately not consumed.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> 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<SubPaletteSwap>)Array.Empty<SubPaletteSwap>()
: 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<TextureChange>)Array.Empty<TextureChange>()
: 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<AnimPartChange>)Array.Empty<AnimPartChange>()
: 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 just the Name field (second after flags). ---
string? name = null;
if (body.Length - pos >= 4)
{
pos += 4; // skip weenieFlags u32
try
{
name = ReadString16L(body, ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, 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, motionState, motionTableId);
}
catch
{
return null;
}
}
private static uint ReadU32(ReadOnlySpan<byte> 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<byte> 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");
string result = System.Text.Encoding.ASCII.GetString(source.Slice(pos, length));
pos += length;
int recordSize = 2 + length;
int padding = (4 - (recordSize & 3)) & 3;
pos += padding;
return result;
}
/// <summary>
/// Read a PackedDword from the stream. Format:
/// <list type="bullet">
/// <item>u16 first. If the top bit (0x8000) is clear, the u16 IS the value (0..0x7FFF).</item>
/// <item>Otherwise, read another u16 and combine: the full 32-bit value
/// is <c>((lowHalfTopBitStripped) &lt;&lt; 16) | highHalfFromNextU16</c>.</item>
/// </list>
/// Ported from ACE's Extensions.WritePackedDword: for values ≤ 32767, emitted as
/// u16; for larger, emitted as <c>(value &lt;&lt; 16) | ((value &gt;&gt; 16) | 0x8000)</c>
/// 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.
/// </summary>
private static uint ReadPackedDword(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Read a PackedDword that was written via <c>WritePackedDwordOfKnownType</c>.
/// That writer strips the <paramref name="knownType"/> 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 <c>knownType</c>).
/// </summary>
private static uint ReadPackedDwordOfKnownType(ReadOnlySpan<byte> 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;
}
/// <summary>
/// Parse the inner <c>MovementData</c> bytes (no header form, as written
/// by ACE's CreateObject path with <c>writer.Write(movementData, false)</c>).
/// We extract the <c>CurrentStyle</c> stance and, when MovementType is
/// <c>Invalid</c> (the typical case for stationary entities like the
/// Foundry's drudge statue), the <c>InterpretedMotionState.ForwardCommand</c>
/// motion command. Both are used by the renderer to compose a MotionTable
/// cycle key and resolve the entity's actual idle pose.
/// <para>
/// Layout — see ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write
/// (header=false) and InterpretedMotionState.cs::Write:
/// </para>
/// <list type="bullet">
/// <item>u8 movementType</item>
/// <item>u8 motionFlags</item>
/// <item>u16 currentStyle (MotionStance)</item>
/// <item>For MovementType.Invalid (==0): InterpretedMotionState body</item>
/// </list>
/// Returns null on truncation; partial results are still returned with
/// whatever fields parsed successfully.
/// </summary>
private static ServerMotionState? TryParseMovementData(ReadOnlySpan<byte> 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;
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
// 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;
// CurrentStyle (0x1)
if ((flags & 0x1u) != 0)
{
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// ForwardCommand (0x2)
if ((flags & 0x2u) != 0)
{
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// SidestepCommand (0x4) — skip
if ((flags & 0x4u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
// TurnCommand (0x8) — skip
if ((flags & 0x8u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
// ForwardSpeed (0x10)
if ((flags & 0x10u) != 0)
{
if (mv.Length - p < 4) goto done;
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, commands);
}
catch
{
return null;
}
}
}