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