diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
new file mode 100644
index 0000000..0a6cf25
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -0,0 +1,263 @@
+using System.Buffers.Binary;
+
+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;
+
+ [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);
+
+ /// 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);
+
+ ///
+ /// 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)
+ {
+ 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;
+
+ if (subPaletteCount > 0)
+ _ = ReadPackedDword(body, ref pos); // overall palette id
+
+ for (int i = 0; i < subPaletteCount; i++)
+ {
+ _ = ReadPackedDword(body, ref pos); // subPaletteId
+ if (body.Length - pos < 2) return null;
+ pos += 2; // offset + length bytes
+ }
+
+ for (int i = 0; i < textureChangeCount; i++)
+ {
+ if (body.Length - pos < 1) return null;
+ pos += 1; // partIndex
+ _ = ReadPackedDword(body, ref pos); // oldTexture
+ _ = ReadPackedDword(body, ref pos); // newTexture
+ }
+
+ for (int i = 0; i < animPartChangeCount; i++)
+ {
+ if (body.Length - pos < 1) return null;
+ pos += 1; // index
+ _ = ReadPackedDword(body, ref pos); // animationId
+ }
+
+ 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, u32 isAutonomous flag
+ 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;
+ pos += (int)movementLen;
+ if (body.Length - pos < 4) return null;
+ pos += 4; // isAutonomous
+ }
+ }
+ else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
+ {
+ if (body.Length - pos < 4) return null;
+ pos += 4;
+ }
+
+ ServerPosition? position = null;
+ 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;
+ 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;
+ }
+
+ uint? setupTableId = null;
+ if ((physicsFlags & PhysicsDescriptionFlag.CSetup) != 0)
+ {
+ if (body.Length - pos < 4) return null;
+ setupTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ }
+
+ // Stop here — further fields (Parent, Children, ObjScale, Friction,
+ // Elasticity, Translucency, Velocity, Acceleration, Omega, DefaultScript,
+ // timestamps, weenie header, ...) aren't needed to render the entity.
+ return new Parsed(guid, position, setupTableId);
+ }
+ 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;
+ }
+
+ ///
+ /// 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;
+ }
+
+ private static void AlignTo4(ref int pos)
+ {
+ int padding = (4 - (pos & 3)) & 3;
+ pos += padding;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
index 082ce3f..a096262 100644
--- a/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
+++ b/tests/AcDream.Core.Net.Tests/LiveHandshakeTests.cs
@@ -406,10 +406,14 @@ public class LiveHandshakeTests
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
- // ---- Step 8: receive the CreateObject flood. ----
+ // ---- Step 8: receive the CreateObject flood + parse bodies. ----
int totalMessages = 0;
int createObjectCount = 0;
+ int createObjectParsed = 0;
+ int createObjectWithPosition = 0;
+ int createObjectWithSetup = 0;
var seenOpcodes = new HashSet();
+ var parsedCreateObjects = new List();
var d8 = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < d8)
{
@@ -428,19 +432,41 @@ public class LiveHandshakeTests
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
seenOpcodes.Add(op);
totalMessages++;
- if (op == 0xF745u) // CreateObject
+ if (op == CreateObject.Opcode)
+ {
createObjectCount++;
+ var parsed = CreateObject.TryParse(body);
+ if (parsed is not null)
+ {
+ createObjectParsed++;
+ parsedCreateObjects.Add(parsed.Value);
+ if (parsed.Value.Position is not null) createObjectWithPosition++;
+ if (parsed.Value.SetupTableId is not null) createObjectWithSetup++;
+ }
+ }
}
}
Console.WriteLine($"[live] step 8 summary: {totalMessages} GameMessages assembled, " +
- $"{createObjectCount} CreateObject");
+ $"{createObjectCount} CreateObject, " +
+ $"{createObjectParsed} parsed, " +
+ $"{createObjectWithPosition} w/position, " +
+ $"{createObjectWithSetup} w/setup");
Console.WriteLine("[live] unique opcodes seen: " +
string.Join(", ", seenOpcodes.Select(o => $"0x{o:X8}")));
- // MILESTONE: if we got at least one CreateObject, the server considers
- // us logged into the world. That's the Phase 4.7 win condition.
+ // Dump the first 10 parsed CreateObjects so we can eyeball whether
+ // the positions match Holtburg and the setup ids look like dat ids.
+ foreach (var co in parsedCreateObjects.Take(10))
+ {
+ string posStr = co.Position is { } p
+ ? $"lb=0x{p.LandblockId:X8} xyz=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2})"
+ : "no position";
+ string setupStr = co.SetupTableId is { } s ? $"setup=0x{s:X8}" : "no setup";
+ Console.WriteLine($"[live] CreateObject guid=0x{co.Guid:X8} {posStr} {setupStr}");
+ }
+
Assert.True(createObjectCount > 0,
- $"Expected at least one CreateObject message post-login, got {createObjectCount}");
+ $"Expected at least one CreateObject post-login, got {createObjectCount}");
}
}