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.
///
///
///
/// 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 Parse 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.
///
///
///
/// 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;
[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 three fields acdream 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,
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.
///
public readonly record struct ServerMotionState(
ushort Stance,
ushort? ForwardCommand,
float? ForwardSpeed = null,
IReadOnlyList? Commands = null);
///
/// 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 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 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");
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;
}
///
/// 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;
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;
// 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((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;
}
}
}