From 9e4313f3d35822f02bf0e9b1cbdfa6d21ed2378e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 15:18:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20CreateObject=20body=20parser=20?= =?UTF-8?q?=E2=80=94=20GUID=20+=20Position=20+=20SetupId=20extracted=20(Ph?= =?UTF-8?q?ase=204.7d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decodes the CreateObject (0xF745) game message body far enough to hand an entity off to acdream's existing IGameState/MeshRenderer pipeline. Ported from ACE's WorldObject_Networking.cs (SerializeCreateObject, SerializeModelData, SerializePhysicsData) and Position.cs. Scope: the parser extracts exactly three fields — - GUID (u32 right after the opcode) - ServerPosition (landblockId + XYZ + rotation quaternion), if the Position bit is set in the PhysicsDescriptionFlag - SetupTableId (setup dat id for the visual mesh chain), if the CSetup bit is set Everything else in a CreateObject body (weenie header, object description, motion tables, palettes, texture overrides, animation frames, velocity, acceleration, omega, scale, friction, elasticity, translucency, default scripts, sequence timestamps, ...) is consumed-or-skipped with just enough bytes to advance past the correct flag-gated sections. The parser stops at the end of PhysicsData — we don't need weenie-header fields for rendering placement. Components parsed in order (all from ACE's serialize routines): 1. Opcode u32 (must be 0xF745) 2. u32 GUID 3. ModelData header (byte 0x11 marker, byte subPaletteCount, byte textureChangeCount, byte animPartChangeCount), followed by PackedDword palette/subPalette fields, texture change records, anim part change records, aligned to 4 bytes at end 4. u32 PhysicsDescriptionFlag 5. u32 PhysicsState (skipped) 6. Conditional Movement/AnimationFrame section 7. Conditional Position section (LandblockId, X, Y, Z, RW, RX, RY, RZ) 8. Conditional MTable/STable/PeTable u32 ids (all skipped) 9. Conditional CSetup u32 (extracted as SetupTableId) The PackedDword reader is a new helper: AC's variable-width uint format where values ≤ 32767 encode as a u16, larger values use a marker bit in the top of the first u16 and a continuation u16. Ported from Extensions.WritePackedDword. LIVE RUN AGAINST THE ACE SERVER (test account, Holtburg): step 4: CharacterList received account=testaccount count=2 character: id=0x5000000A name=+Acdream character: id=0x50000008 name=+Wdw sent CharacterEnterWorldRequest step 6: CharacterEnterWorldServerReady received sent CharacterEnterWorld(guid=0x5000000A) step 8 summary: 83 GameMessages assembled, 68 CreateObject, 68 parsed, 52 w/position, 68 w/setup First 10 parsed CreateObjects: guid=0x5000000A lb=0xA9B40021 xyz=(104.89,15.05,94.01) setup=0x02000001 guid=0x80000600 no position setup=0x02000181 guid=0x800005FF no position setup=0x02000B77 guid=0x80000603 no position setup=0x02000176 guid=0x80000604 no position setup=0x02000D5C guid=0x80000694 no position setup=0x020005FF guid=0x80000697 no position setup=0x02000921 guid=0x80000601 no position setup=0x02000179 guid=0x80000605 no position setup=0x02000155 guid=0x80000695 no position setup=0x020005FF The first line is +Acdream himself — GUID matches what we picked from CharacterList, landblock 0xA9B4 is Holtburg (the area we already render), setup 0x02000001 is the default humanoid player mesh. The other 67 are NPCs/weenies/scenery-weenies in the same area; the 16 without positions are inventory items whose position is inherited from the parent. ALL 68 CreateObjects parsed cleanly — no short reads, no format errors. Phase 4.7d proves byte-level compatibility with ACE's outbound network serialization format. The remaining Phase 4 work (WorldSession type + GameWindow wiring) is glue code above a codec that now speaks the real AC wire format. Tests: 77 core + 83 net (+1 live test) = 161 passing, all green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core.Net/Messages/CreateObject.cs | 263 ++++++++++++++++++ .../LiveHandshakeTests.cs | 38 ++- 2 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/CreateObject.cs 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}"); } }