diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0bae242..00ff3ce 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -682,6 +682,13 @@ public sealed class GameWindow : IDisposable /// private readonly Dictionary _entitiesByServerGuid = new(); private readonly Dictionary _liveEntityInfoByGuid = new(); + /// + /// Latest for each + /// guid. Captured at the end of so + /// can reuse the position/setup/motion + /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. + /// + private readonly Dictionary _lastSpawnByGuid = new(); private uint? _selectedTargetGuid; private readonly record struct LiveEntityInfo( string? Name, @@ -1476,6 +1483,7 @@ public sealed class GameWindow : IDisposable _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.VectorUpdated += OnLiveVectorUpdated; _liveSession.TeleportStarted += OnTeleportStarted; + _liveSession.AppearanceUpdated += OnLiveAppearanceUpdated; // Phase 6c — PlayScript (0xF754) arrives from the server as // a (guid, scriptId) pair. Resolve the guid's current world @@ -1988,7 +1996,7 @@ public sealed class GameWindow : IDisposable && setup.Parts.Count >= 10; if (dumpClothing) { - Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} APC={animPartChanges.Count} ==="); + Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} flatten.Count={flat.Count} APC={animPartChanges.Count} ==="); foreach (var c in animPartChanges) Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}"); @@ -2158,14 +2166,27 @@ public sealed class GameWindow : IDisposable var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale); var meshRefs = new List(); + int dumpClothingTotalTris = 0; for (int partIdx = 0; partIdx < parts.Count; partIdx++) { var mr = parts[partIdx]; var gfx = _dats.Get(mr.GfxObjId); - if (gfx is null) continue; + if (gfx is null) + { + if (dumpClothing) + Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); + continue; + } _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); + if (dumpClothing) + { + int tris = 0; int subs = 0; + foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; } + dumpClothingTotalTris += tris; + Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}"); + } IReadOnlyDictionary? surfaceOverrides = null; if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides)) @@ -2194,6 +2215,8 @@ public sealed class GameWindow : IDisposable $"(guid=0x{spawn.Guid:X8})"); return; } + if (dumpClothing) + Console.WriteLine($" TOTAL tris={dumpClothingTotalTris} meshRefs={meshRefs.Count} (parts.Count={parts.Count})"); // Build optional per-entity palette override from the server's base // palette + subpalette overlays. The renderer applies these to @@ -2241,6 +2264,10 @@ public sealed class GameWindow : IDisposable // UpdateMotion / UpdatePosition events can reseat this entity by guid. _entitiesByServerGuid[spawn.Guid] = entity; + // Cache the spawn so OnLiveAppearanceUpdated can replay it with new + // appearance fields when a later 0xF625 ObjDescEvent arrives. + _lastSpawnByGuid[spawn.Guid] = spawn; + // Commit B 2026-04-29 — live-entity collision registration. The // local player is the simulator (its PhysicsBody is the source of // truth for our own movement); only remotes register as targets. @@ -2470,6 +2497,40 @@ public sealed class GameWindow : IDisposable } } + /// + /// Server broadcast a 0xF625 ObjDescEvent — a creature/player's + /// appearance changed (equip / unequip / tailoring / recipe result / + /// character option toggle). The wire payload only carries the new + /// ModelData (palette + texture + animpart changes), not position or + /// motion, so we splice it onto the cached spawn and replay through + /// . The dedup at the start of + /// tears down the previous + /// rendering state (GpuWorldState entry, animated entity, collision + /// registration) before rebuilding. + /// + private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update) + { + if (!_lastSpawnByGuid.TryGetValue(update.Guid, out var oldSpawn)) + { + // Server can broadcast ObjDescEvent before we've seen a + // CreateObject for this guid (race on landblock entry, or + // if the entity is in a state we couldn't render). Drop — + // when CreateObject lands, ACE includes the same ModelData + // body inside it, so the appearance won't be lost. + return; + } + + var md = update.ModelData; + var newSpawn = oldSpawn with + { + AnimPartChanges = md.AnimPartChanges, + TextureChanges = md.TextureChanges, + SubPalettes = md.SubPalettes, + BasePaletteId = md.BasePaletteId, + }; + OnLiveEntitySpawned(newSpawn); + } + /// /// Commit B 2026-04-29 — register a live (server-spawned) entity into /// the as a single collision body. @@ -2580,6 +2641,7 @@ public sealed class GameWindow : IDisposable _remoteLastMove.Remove(serverGuid); _liveEntityInfoByGuid.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid); + _lastSpawnByGuid.Remove(serverGuid); if (_selectedTargetGuid == serverGuid) _selectedTargetGuid = null; @@ -3614,6 +3676,13 @@ public sealed class GameWindow : IDisposable var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); DumpMovementTruthServerEcho(update, worldPos); + // Keep the cached spawn's Position in sync with server truth so a + // later ObjDescEvent (which only carries new appearance, not new + // position) re-applies at the entity's CURRENT location instead of + // popping back to its login spot. See OnLiveAppearanceUpdated. + if (_lastSpawnByGuid.TryGetValue(update.Guid, out var cached)) + _lastSpawnByGuid[update.Guid] = cached with { Position = update.Position }; + // Capture the pre-update render position for the soft-snap residual // calculation below. Assign entity.Position to the server truth up // front; if we then compute a snap residual, we restore the rendered diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 4b68d42..a5fcd7b 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -269,6 +269,18 @@ public static class CreateObject /// public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId); + /// + /// The ModelData block — palette/texture/animpart changes — that lives + /// inside both CreateObject (initial spawn) and ObjDescEvent (0xF625 + /// appearance update). Factored out so both sites parse the same wire + /// shape with one implementation. + /// + public readonly record struct ModelData( + uint? BasePaletteId, + IReadOnlyList SubPalettes, + IReadOnlyList TextureChanges, + IReadOnlyList AnimPartChanges); + /// /// Parse a reassembled CreateObject body. must /// start with the 4-byte opcode. Returns null if the body is @@ -310,64 +322,11 @@ public static class CreateObject 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); + var modelData = ReadModelData(body, ref pos); + uint? basePaletteId = modelData.BasePaletteId; + var subPalettes = modelData.SubPalettes; + var textureChanges = modelData.TextureChanges; + var animParts = modelData.AnimPartChanges; // --- PhysicsData --- if (body.Length - pos < 8) return null; @@ -559,6 +518,80 @@ public static class CreateObject } } + /// + /// Read the ModelData block — palette swaps + texture overrides + + /// animation-part replacements — that lives inside both CreateObject + /// (initial spawn) and ObjDescEvent (0xF625 appearance update). + /// + /// Layout: byte marker (0x11), byte subPaletteCount, byte + /// textureChangeCount, byte animPartChangeCount. Then: + /// + /// BasePaletteId (PackedDword of palette type), only present when subPaletteCount > 0 + /// SubPalettes[subPaletteCount]: PackedDword id + byte offset + byte length + /// TextureChanges[textureChangeCount]: byte partIndex + PackedDword oldTex + PackedDword newTex + /// AnimPartChanges[animPartChangeCount]: byte partIndex + PackedDword newModelId + /// 4-byte alignment pad + /// + /// + /// Throws on truncated input — + /// callers wrap in try/catch and convert to a null result. Advances + /// past the alignment pad so the caller can + /// continue reading the next field. + /// + public static ModelData ReadModelData(ReadOnlySpan body, ref int pos) + { + if (body.Length - pos < 4) throw new FormatException("truncated ModelData header"); + 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) throw new FormatException("truncated SubPaletteSwap"); + 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) throw new FormatException("truncated TextureChange"); + 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); + } + + // ACE writes NewModelId via WritePackedDwordOfKnownType(0x01000000) + // which strips the high-byte type if present before packing. + // ReadPackedDwordOfKnownType ORs it back on read. + var animParts = animPartChangeCount == 0 + ? (IReadOnlyList)Array.Empty() + : new AnimPartChange[animPartChangeCount]; + for (int i = 0; i < animPartChangeCount; i++) + { + if (body.Length - pos < 1) throw new FormatException("truncated AnimPartChange"); + byte partIndex = body[pos]; pos += 1; + uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix); + ((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId); + } + + AlignTo4(ref pos); + return new ModelData(basePaletteId, subPalettes, textureChanges, animParts); + } + private static uint ReadU32(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 4) throw new FormatException("truncated u32"); diff --git a/src/AcDream.Core.Net/Messages/ObjDescEvent.cs b/src/AcDream.Core.Net/Messages/ObjDescEvent.cs new file mode 100644 index 0000000..b6d966a --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ObjDescEvent.cs @@ -0,0 +1,74 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound ObjDescEvent GameMessage (opcode 0xF625). ACE +/// broadcasts this whenever a creature/player's appearance changes after +/// the initial spawn — equip / unequip +/// (Creature_Equipment.cs:365), tailoring (Tailoring.cs:504), recipe +/// results (RecipeManager.cs:403), character-option toggles. Skunkwors +/// protocol docs: "F625: Change Model — Sent whenever a character changes +/// their clothes. It contains the entire description of what they're +/// wearing (and possibly their facial features as well). This message is +/// only sent for changes; when the character is first created, the body +/// of this message is included inside the creation message." +/// +/// Retail handles it via SmartBox::HandleObjDescEvent +/// (named-retail symbol 0x453340). acdream silently dropped it through +/// 2026-05-06 — the bug was that retail-driven characters observed from +/// acdream rendered with the wrong skin/hair palettes because the +/// follow-up appearance updates were never applied. +/// +/// Wire layout (ACE WorldObject_Networking.cs:48-54 +/// SerializeUpdateModelData): +/// +/// u32 opcode (0xF625) +/// u32 guid — target object +/// ModelData block — see +/// u32 instanceSequence +/// u32 visualDescSequence +/// +/// +public static class ObjDescEvent +{ + public const uint Opcode = 0xF625u; + + /// + /// One ObjDescEvent: target guid + the new ModelData. Sequence + /// counters are read but not surfaced (subscribers don't need them + /// — the event always carries the full new appearance). + /// + public readonly record struct Parsed(uint Guid, CreateObject.ModelData ModelData); + + /// + /// Parse an ObjDescEvent body (must start with the 4-byte opcode). + /// Returns null on truncation or wrong opcode. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + try + { + int pos = 0; + if (body.Length - pos < 4) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + if (opcode != Opcode) return null; + + if (body.Length - pos < 4) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + + var modelData = CreateObject.ReadModelData(body, ref pos); + + // Trailing instanceSeq + visualDescSeq are read for completeness + // but not surfaced — subscribers re-render unconditionally on + // every event since each carries the full appearance. + return new Parsed(guid, modelData); + } + catch + { + return null; + } + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3e76509..af7d695 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -139,6 +139,20 @@ public sealed class WorldSession : IDisposable /// public event Action? TeleportStarted; + /// + /// Fires when the server broadcasts an ObjDescEvent (0xF625) — + /// a creature/player's appearance changed after the initial CreateObject + /// (equip / unequip / tailoring / recipe result / character option toggle). + /// Subscribers re-apply the new ModelData to the existing entity: + /// AnimPartChanges replace mesh refs, TextureChanges update per-part + /// surface texture overrides, and SubPalettes rebuild the palette + /// override (the channel that carries skin/hair tone). Without this, + /// retail-driven characters observed from acdream end up "stuck" at + /// whatever appearance was in their first CreateObject — see issue + /// notes in commit history around 2026-05-06. + /// + public event Action? AppearanceUpdated; + /// /// Phase H.1: fires when a local or ranged speech message (0x02BB / /// 0x02BC) is received. Subscribers typically feed these into a @@ -314,12 +328,18 @@ public sealed class WorldSession : IDisposable private readonly FragmentAssembler _assembler = new(); // Issue #5 diagnostics (env-var-gated): - // ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode - // ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse + // ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode + // ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse + // ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject + // with body len, target guid, hex preview. Used to + // debug remote-player appearance asymmetry (retail + // observer in acdream renders wrong skin/hair). private static readonly bool DumpOpcodesEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1"; private static readonly bool DumpVitalsEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; + private static readonly bool DumpAppearanceEnabled = + Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1"; private readonly System.Collections.Generic.HashSet _seenUnhandledOpcodes = new(); private IsaacRandom? _inboundIsaac; @@ -861,6 +881,36 @@ public sealed class WorldSession : IDisposable _teleportSequence = sequence; // track for outbound movement messages TeleportStarted?.Invoke(sequence); } + else if (op == ObjDescEvent.Opcode) + { + // 0xF625 ObjDescEvent — per-entity appearance update. ACE + // broadcasts on equip/unequip/tailoring/recipe/option-change + // (Creature_Equipment.cs:365, Tailoring.cs:504, + // RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27). + // Retail handler: SmartBox::HandleObjDescEvent (named-retail + // 0x453340). Body layout: u32 opcode | u32 guid | ModelData | + // u32 instanceSeq | u32 visualDescSeq. + var parsed = ObjDescEvent.TryParse(body); + if (parsed is not null) + { + if (DumpAppearanceEnabled) + { + var md = parsed.Value.ModelData; + Console.WriteLine($"appearance: 0xF625 guid=0x{parsed.Value.Guid:X8} basePal=0x{(md.BasePaletteId ?? 0):X8} subPals={md.SubPalettes.Count} texChanges={md.TextureChanges.Count} animParts={md.AnimPartChanges.Count}"); + foreach (var sp in md.SubPalettes) + Console.WriteLine($" SP id=0x{sp.SubPaletteId:X8} offset={sp.Offset} length={sp.Length}"); + foreach (var tc in md.TextureChanges) + Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}"); + foreach (var apc in md.AnimPartChanges) + Console.WriteLine($" APC part={apc.PartIndex:D2} -> gfx=0x{apc.NewModelId:X8}"); + } + AppearanceUpdated?.Invoke(parsed.Value); + } + else if (DumpAppearanceEnabled) + { + Console.WriteLine($"appearance: 0xF625 PARSE FAILED body.len={body.Length}"); + } + } else if (DumpOpcodesEnabled) { // ACDREAM_DUMP_OPCODES=1 — emit a one-line trace per diff --git a/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs new file mode 100644 index 0000000..5610dc2 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs @@ -0,0 +1,153 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class ObjDescEventTests +{ + [Fact] + public void TryParse_RejectsWrongOpcode() + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xF745u); // CreateObject opcode + Assert.Null(ObjDescEvent.TryParse(body)); + } + + [Fact] + public void TryParse_RejectsTruncatedBody() + { + Assert.Null(ObjDescEvent.TryParse(new byte[3])); + } + + /// + /// Round-trip a synthesized body: opcode + guid + ModelData (3 SubPalettes, + /// 4 TextureChanges, 0 AnimPartChanges — same shape as the captured retail + /// 152-byte +Je ObjDescEvent body) + trailing sequence pair. Verifies the + /// parser surfaces the same fields the writer wrote. + /// + [Fact] + public void TryParse_SynthesizedBody_ExtractsGuidAndModelData() + { + // Build a body matching the wire shape we see from ACE. + var bytes = new List(); + AppendU32(bytes, ObjDescEvent.Opcode); + AppendU32(bytes, 0x50000001u); // target guid + + // ModelData header: marker, subPalCount, texCount, animPartCount. + bytes.Add(0x11); + bytes.Add(3); // subPalCount + bytes.Add(4); // texChangeCount + bytes.Add(0); // animPartCount + + // BasePaletteId (palette type prefix stripped before packing). + AppendPackedDword(bytes, 0x0400007Eu, 0x04000000u); + + // SubPalettes — three skin/hair-style overlays at varied offsets. + AppendPackedDword(bytes, 0x04001FE3u, 0x04000000u); + bytes.Add(24); bytes.Add(8); + AppendPackedDword(bytes, 0x040002BAu, 0x04000000u); + bytes.Add(0); bytes.Add(24); + AppendPackedDword(bytes, 0x040002BCu, 0x04000000u); + bytes.Add(32); bytes.Add(8); + + // TextureChanges — four part textures. + for (byte partIdx = 0; partIdx < 4; partIdx++) + { + bytes.Add(partIdx); + AppendPackedDword(bytes, 0x05000100u + partIdx, 0x05000000u); + AppendPackedDword(bytes, 0x05000200u + partIdx, 0x05000000u); + } + + // 4-byte align after AnimPartChanges (none here, so just align). + while (bytes.Count % 4 != 0) bytes.Add(0); + + // Trailing instance + visual-desc sequences (consumed but ignored). + AppendU32(bytes, 0x12345678u); + AppendU32(bytes, 0x9ABCDEF0u); + + var parsed = ObjDescEvent.TryParse(bytes.ToArray()); + + Assert.NotNull(parsed); + Assert.Equal(0x50000001u, parsed!.Value.Guid); + + var md = parsed.Value.ModelData; + Assert.Equal(0x0400007Eu, md.BasePaletteId); + Assert.Equal(3, md.SubPalettes.Count); + Assert.Equal(0x04001FE3u, md.SubPalettes[0].SubPaletteId); + Assert.Equal(24, md.SubPalettes[0].Offset); + Assert.Equal(8, md.SubPalettes[0].Length); + Assert.Equal(0x040002BAu, md.SubPalettes[1].SubPaletteId); + Assert.Equal(0, md.SubPalettes[1].Offset); + Assert.Equal(24, md.SubPalettes[1].Length); + + Assert.Equal(4, md.TextureChanges.Count); + Assert.Equal(0, md.TextureChanges[0].PartIndex); + Assert.Equal(0x05000100u, md.TextureChanges[0].OldTexture); + Assert.Equal(0x05000200u, md.TextureChanges[0].NewTexture); + Assert.Equal(3, md.TextureChanges[3].PartIndex); + + Assert.Empty(md.AnimPartChanges); + } + + /// + /// Confirms ReadModelData (the shared helper) round-trips identically + /// when called from CreateObject and from ObjDescEvent — same bytes, + /// same parsed output. Guards against the two callers drifting. + /// + [Fact] + public void ReadModelData_SameOutputFromBothCallers() + { + // Bare ModelData block — used as a substring in both messages. + var modelDataBytes = new List(); + modelDataBytes.Add(0x11); + modelDataBytes.Add(1); // subPalCount + modelDataBytes.Add(0); // texCount + modelDataBytes.Add(0); // animPartCount + AppendPackedDword(modelDataBytes, 0x0400007Eu, 0x04000000u); + AppendPackedDword(modelDataBytes, 0x04001084u, 0x04000000u); + modelDataBytes.Add(80); modelDataBytes.Add(12); + while (modelDataBytes.Count % 4 != 0) modelDataBytes.Add(0); + + ReadOnlySpan span = modelDataBytes.ToArray(); + int pos = 0; + var md = CreateObject.ReadModelData(span, ref pos); + + Assert.Equal(0x0400007Eu, md.BasePaletteId); + Assert.Single(md.SubPalettes); + Assert.Equal(0x04001084u, md.SubPalettes[0].SubPaletteId); + Assert.Equal(80, md.SubPalettes[0].Offset); + Assert.Equal(12, md.SubPalettes[0].Length); + } + + private static void AppendU32(List dest, uint value) + { + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(tmp, value); + dest.AddRange(tmp.ToArray()); + } + + /// + /// Mirror of ACE's WritePackedDwordOfKnownType: strip the type prefix + /// if it matches , then write as a 16- or + /// 32-bit packed value. + /// + private static void AppendPackedDword(List dest, uint value, uint knownType) + { + uint packed = (value & 0xFF000000u) == knownType ? (value & ~knownType) : value; + if (packed <= 0x7FFFu) + { + Span tmp = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(tmp, (ushort)packed); + dest.AddRange(tmp.ToArray()); + } + else + { + ushort high = (ushort)((packed >> 16) | 0x8000); + ushort low = (ushort)(packed & 0xFFFFu); + Span tmp = stackalloc byte[4]; + BinaryPrimitives.WriteUInt16LittleEndian(tmp, high); + BinaryPrimitives.WriteUInt16LittleEndian(tmp.Slice(2), low); + dest.AddRange(tmp.ToArray()); + } + } +}