feat(net): CreateObject body parser — GUID + Position + SetupId extracted (Phase 4.7d)
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) <noreply@anthropic.com>
This commit is contained in:
parent
94da385ff4
commit
9e4313f3d3
2 changed files with 295 additions and 6 deletions
263
src/AcDream.Core.Net/Messages/CreateObject.cs
Normal file
263
src/AcDream.Core.Net/Messages/CreateObject.cs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>CreateObject</c> GameMessage (opcode <c>0xF745</c>). 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// acdream's parser extracts only the fields needed to hand the spawn off
|
||||||
|
/// to <c>IGameState</c>:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>GUID</b> — always at the start of the body (after the opcode).</item>
|
||||||
|
/// <item><b>Position</b> (landblock id + local XYZ + rotation quaternion) — present
|
||||||
|
/// when <see cref="PhysicsDescriptionFlag.Position"/> is set in the physics
|
||||||
|
/// description flags. We need this to place the entity in the world.</item>
|
||||||
|
/// <item><b>SetupTableId</b> — present when <see cref="PhysicsDescriptionFlag.CSetup"/>
|
||||||
|
/// is set. This is the dat-id for the visual model
|
||||||
|
/// (<c>Setup</c>/<c>GfxObj</c> chain) that acdream's existing
|
||||||
|
/// SetupMesh + GfxObjMesh pipeline already knows how to render.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// 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 <c>Parse</c> 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.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Ported by reading <c>ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs</c>
|
||||||
|
/// (SerializeCreateObject, SerializeModelData, SerializePhysicsData) plus
|
||||||
|
/// <c>ACE.Entity/Position.cs</c> and <c>PhysicsDescriptionFlag.cs</c>.
|
||||||
|
/// See NOTICE.md.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct Parsed(
|
||||||
|
uint Guid,
|
||||||
|
ServerPosition? Position,
|
||||||
|
uint? SetupTableId);
|
||||||
|
|
||||||
|
/// <summary>A server-side position: landblock id + local XYZ + unit quaternion rotation.</summary>
|
||||||
|
public readonly record struct ServerPosition(
|
||||||
|
uint LandblockId,
|
||||||
|
float PositionX, float PositionY, float PositionZ,
|
||||||
|
float RotationW, float RotationX, float RotationY, float RotationZ);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
||||||
|
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
|
||||||
|
/// malformed (truncated field); returns a populated <see cref="Parsed"/>
|
||||||
|
/// on success. The parser stops at the end of PhysicsData; subsequent
|
||||||
|
/// weenie-header fields are deliberately not consumed.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> 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<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read a PackedDword from the stream. Format:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>u16 first. If the top bit (0x8000) is clear, the u16 IS the value (0..0x7FFF).</item>
|
||||||
|
/// <item>Otherwise, read another u16 and combine: the full 32-bit value
|
||||||
|
/// is <c>((lowHalfTopBitStripped) << 16) | highHalfFromNextU16</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Ported from ACE's Extensions.WritePackedDword: for values ≤ 32767, emitted as
|
||||||
|
/// u16; for larger, emitted as <c>(value << 16) | ((value >> 16) | 0x8000)</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static uint ReadPackedDword(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -406,10 +406,14 @@ public class LiveHandshakeTests
|
||||||
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
|
CharacterEnterWorld.BuildEnterWorldBody(chosen.Id, user),
|
||||||
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
|
$"CharacterEnterWorld(guid=0x{chosen.Id:X8})");
|
||||||
|
|
||||||
// ---- Step 8: receive the CreateObject flood. ----
|
// ---- Step 8: receive the CreateObject flood + parse bodies. ----
|
||||||
int totalMessages = 0;
|
int totalMessages = 0;
|
||||||
int createObjectCount = 0;
|
int createObjectCount = 0;
|
||||||
|
int createObjectParsed = 0;
|
||||||
|
int createObjectWithPosition = 0;
|
||||||
|
int createObjectWithSetup = 0;
|
||||||
var seenOpcodes = new HashSet<uint>();
|
var seenOpcodes = new HashSet<uint>();
|
||||||
|
var parsedCreateObjects = new List<CreateObject.Parsed>();
|
||||||
var d8 = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
var d8 = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||||
while (DateTime.UtcNow < d8)
|
while (DateTime.UtcNow < d8)
|
||||||
{
|
{
|
||||||
|
|
@ -428,19 +432,41 @@ public class LiveHandshakeTests
|
||||||
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
uint op = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||||
seenOpcodes.Add(op);
|
seenOpcodes.Add(op);
|
||||||
totalMessages++;
|
totalMessages++;
|
||||||
if (op == 0xF745u) // CreateObject
|
if (op == CreateObject.Opcode)
|
||||||
|
{
|
||||||
createObjectCount++;
|
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, " +
|
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: " +
|
Console.WriteLine("[live] unique opcodes seen: " +
|
||||||
string.Join(", ", seenOpcodes.Select(o => $"0x{o:X8}")));
|
string.Join(", ", seenOpcodes.Select(o => $"0x{o:X8}")));
|
||||||
|
|
||||||
// MILESTONE: if we got at least one CreateObject, the server considers
|
// Dump the first 10 parsed CreateObjects so we can eyeball whether
|
||||||
// us logged into the world. That's the Phase 4.7 win condition.
|
// 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,
|
Assert.True(createObjectCount > 0,
|
||||||
$"Expected at least one CreateObject message post-login, got {createObjectCount}");
|
$"Expected at least one CreateObject post-login, got {createObjectCount}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue