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.
///
///
///
/// (acclient.h:2815) carries flag
/// bits like ETHEREAL_PS=0x4, IGNORE_COLLISIONS_PS=0x10,
/// HAS_PHYSICS_BSP_PS=0x10000 — the bits retail's
/// FindObjCollisions reads to short-circuit ethereal /
/// no-collision entities. Pre-2026-04-29 (Commit A of the live-entity
/// collision port) the parser silently dropped this field.
///
///
/// is the PWD._bitfield
/// trailer (acclient.h:6431-6463) — bits include BF_PLAYER (0x8),
/// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
/// BF_PKLITE_PKSTATUS (0x2000000). Decoded into
/// EntityCollisionFlags at registration time for the PvP
/// exemption gate.
///
///
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,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
// wire. Default to null when their PhysicsDescriptionFlag bits
// weren't set; subscribers fall back to PhysicsBody constructor
// defaults (0.05f elasticity, 0.5f friction).
float? Friction = null,
float? Elasticity = null,
// 2026-05-15: optional WeenieHeader tail. The retail
// `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the
// R-key Use action does anything. (Useability & USEABLE_REMOTE
// (0x20)) != 0 means the entity is useable from the world via
// mouse Use. Signs / banners / decorative scenery have
// USEABLE_UNDEF (0x0) here — selecting them via left-click is
// fine, but R-key Use should be a no-op (retail-faithful: the
// character does not walk toward; nothing happens).
// UseRadius is the use-action's reach in meters; doubles as
// a sizing hint for selection indicators on entities that
// publish it.
uint? Useability = null,
float? UseRadius = null);
///
/// 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,
MoveToPathData? MoveToPath = 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;
///
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
/// chasing its target. Cross-checked against acclient.h:31423-31443
/// (named retail) + ACE MovementParamFlags.MoveTowards.
///
public bool MoveTowards => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x200u) != 0;
}
///
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
/// Wire layout per MovementParameters::UnPackNet @ 0x0052ac50
/// + the leading Origin + optional target guid for type 6:
///
/// - type 6 (MoveToObject) only: u32 TargetGuid
/// - Origin: u32 cellId, then 3 floats (local x/y/z within the landblock)
/// - MovementParameters (28 bytes, exact retail order):
/// u32 flags, f32 distance_to_object, f32 min_distance,
/// f32 fail_distance, f32 speed, f32 walk_run_threshhold,
/// f32 desired_heading
///
/// (The trailing runRate float is captured separately on
/// .)
///
public readonly record struct MoveToPathData(
uint? TargetGuid,
uint OriginCellId,
float OriginX,
float OriginY,
float OriginZ,
float DistanceToObject,
float MinDistance,
float FailDistance,
float WalkRunThreshold,
float DesiredHeading);
///
/// 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);
///
/// The ModelData block — palette/texture/animpart changes — that lives
/// inside both CreateObject (initial spawn) and ObjDescEvent (0xF625
/// appearance update). Factored out so both sites parse the same wire
/// shape with one implementation.
///
public readonly record struct ModelData(
uint? BasePaletteId,
IReadOnlyList SubPalettes,
IReadOnlyList TextureChanges,
IReadOnlyList AnimPartChanges);
///
/// 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;
// Commit A 2026-04-29 — live-entity collision plumbing. PhysicsState
// (acclient.h:2815) was previously skipped at line ~337; the PWD
// _bitfield (acclient.h:6431-6463) was previously discarded as
// "ObjectDescriptionFlags" at the WeenieHeader trailer.
uint? physicsState = null;
uint? objectDescriptionFlags = null;
// L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded
// when their PhysicsDescriptionFlag bits are set. Default values
// come from PhysicsBody constructors; these overrides drive the
// velocity-reflection bounce magnitude per object (e.g., bouncier
// platforms vs. inert walls).
float? friction = null;
float? elasticity = null;
try
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
if (opcode != Opcode)
return null;
uint guid = ReadU32(body, ref pos);
var modelData = ReadModelData(body, ref pos);
uint? basePaletteId = modelData.BasePaletteId;
var subPalettes = modelData.SubPalettes;
var textureChanges = modelData.TextureChanges;
var animParts = modelData.AnimPartChanges;
// --- PhysicsData ---
if (body.Length - pos < 8) return null;
var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
// PhysicsState (acclient.h:2815). Previously skipped, now
// surfaced for live-entity collision (Commit A 2026-04-29).
physicsState = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
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)
{
if (body.Length - pos < 4) return PartialResult();
friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0)
{
// L.3b (2026-04-30): capture instead of skipping. The wire
// float is the per-object elasticity used by the velocity-
// reflection bounce (CPhysicsObj::set_elasticity at
// acclient_2013_pseudo_c.txt:277817, clamped to [0, 0.1]).
// Was previously dropped — every object got the default
// 0.05f, so server-set bouncier surfaces felt identical to
// walls.
if (body.Length - pos < 4) return PartialResult();
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
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;
uint weenieFlags = 0;
if (body.Length - pos >= 4)
{
weenieFlags = ReadU32(body, ref pos);
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)
{
// ObjectDescriptionFlags = retail PWD._bitfield
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
// acclient_2013_pseudo_c.txt:406898-406918 read for
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
// discarded; now surfaced for the PvP collision rule.
objectDescriptionFlags = ReadU32(body, ref pos);
}
AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
// --- WeenieHeader optional tail (2026-05-15): walk the
// conditional fields up through Useability + UseRadius.
//
// Wire order is fixed by ACE WorldObject_Networking.cs:87-114
// and matches retail PWD::Pack order. We MUST skip every
// preceding optional field (even those we don't care about)
// because each one moves the parse cursor.
//
// Field bit width decoded?
// ------- ------ -------- --------
// weenieFlags2 conditional on objDescFlags & 0x80000000 (BF_INCLUDES_SECOND_HEADER)
// u32 skipped
// PluralName 0x1 String16L (variable, padded to 4) skipped
// ItemCapacity 0x2 1 byte skipped
// ContainerCap 0x4 1 byte skipped
// AmmoType 0x100 u16 skipped
// Value 0x8 u32 skipped
// Useability 0x10 u32 KEPT
// UseRadius 0x20 f32 KEPT
//
// Wrapped in try/catch — if a malformed entity truncates the
// tail we still return the prefix fields. Most spawned entities
// either have all of these or none of them.
uint? useability = null;
float? useRadius = null;
try
{
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x80000000u) != 0;
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
if ((weenieFlags & 0x00000001u) != 0) // PluralName
_ = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
pos += 2;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
pos += 4;
}
if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc UseRadius");
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
useability, useRadius);
// 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,
PhysicsState: physicsState,
ObjectDescriptionFlags: objectDescriptionFlags,
Friction: friction,
Elasticity: elasticity);
}
catch
{
return null;
}
}
///
/// Read the ModelData block — palette swaps + texture overrides +
/// animation-part replacements — that lives inside both CreateObject
/// (initial spawn) and ObjDescEvent (0xF625 appearance update).
///
/// Layout: byte marker (0x11), byte subPaletteCount, byte
/// textureChangeCount, byte animPartChangeCount. Then:
///
/// - BasePaletteId (PackedDword of palette type), only present when subPaletteCount > 0
/// - SubPalettes[subPaletteCount]: PackedDword id + byte offset + byte length
/// - TextureChanges[textureChangeCount]: byte partIndex + PackedDword oldTex + PackedDword newTex
/// - AnimPartChanges[animPartChangeCount]: byte partIndex + PackedDword newModelId
/// - 4-byte alignment pad
///
///
/// Throws on truncated input —
/// callers wrap in try/catch and convert to a null result. Advances
/// past the alignment pad so the caller can
/// continue reading the next field.
///
public static ModelData ReadModelData(ReadOnlySpan body, ref int pos)
{
if (body.Length - pos < 4) throw new FormatException("truncated ModelData header");
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) throw new FormatException("truncated SubPaletteSwap");
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) throw new FormatException("truncated TextureChange");
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);
}
// ACE writes NewModelId via WritePackedDwordOfKnownType(0x01000000)
// which strips the high-byte type if present before packing.
// ReadPackedDwordOfKnownType ORs it back on read.
var animParts = animPartChangeCount == 0
? (IReadOnlyList)Array.Empty()
: new AnimPartChange[animPartChangeCount];
for (int i = 0; i < animPartChangeCount; i++)
{
if (body.Length - pos < 1) throw new FormatException("truncated AnimPartChange");
byte partIndex = body[pos]; pos += 1;
uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix);
((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId);
}
AlignTo4(ref pos);
return new ModelData(basePaletteId, subPalettes, textureChanges, animParts);
}
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;
}
}