diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 57419a17..6ca8697f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2632,7 +2632,11 @@ public sealed class GameWindow : IDisposable
{
// D.5.1: enrich a known inventory/equipped item (stubbed from PlayerDescription)
// with the icon/name/type its CreateObject carries, so the toolbar can render it.
- Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty, (AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0));
+ // D.5.1 (2026-06-17): also pass overlay/underlay ids from the extended
+ // WeenieHeader tail so IconComposer composites all icon layers.
+ Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty,
+ (AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0),
+ spawn.IconOverlayId, spawn.IconUnderlayId);
// Phase A.1 hotfix: live CreateObject handler reads dats extensively
// (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index db3c8a7b..f0d2b65d 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -145,7 +145,18 @@ public static class CreateObject
// a sizing hint for selection indicators on entities that
// publish it.
uint? Useability = null,
- float? UseRadius = null);
+ float? UseRadius = null,
+ // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
+ // WeenieHeader optional tail. IconOverlayId is gated by
+ // WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
+ // IconUnderlayId is gated by WeenieHeaderFlag2.IconUnderlay (0x01)
+ // in weenieFlags2 (present when objDescFlags bit 0x04000000 is set).
+ // Sourced from ACE WorldObject_Networking.cs:202-206. Zero when
+ // the server did not send the field (most entities have neither).
+ // IconComposer.GetIcon already composites these layers in the correct
+ // retail order (underlay / base / overlay+tint / effect).
+ uint IconOverlayId = 0,
+ uint IconUnderlayId = 0);
///
/// The relevant subset of the server-sent MovementData /
@@ -539,31 +550,62 @@ public static class CreateObject
catch { /* truncated name — partial result is still useful */ }
}
- // --- WeenieHeader optional tail (2026-05-15): walk the
- // conditional fields up through Useability + UseRadius.
+ // --- WeenieHeader optional tail: walk every conditional field
+ // in EXACT ACE write order (WorldObject_Networking.cs:87-219)
+ // so the cursor reaches IconOverlay + IconUnderlay.
//
- // Wire order is fixed by ACE WorldObject_Networking.cs:87-114
- // and matches retail PWD::Pack order. We MUST skip every
- // preceding optional field (even those we don't care about)
- // because each one moves the parse cursor.
+ // We MUST skip every field that precedes IconOverlay even when
+ // we don't need its value — each one occupies bytes on the wire
+ // and a cursor error here would desync ALL downstream optional
+ // reads for the rest of this entity's packet.
//
- // Field bit width decoded?
- // ------- ------ -------- --------
- // weenieFlags2 conditional on objDescFlags & 0x80000000 (BF_INCLUDES_SECOND_HEADER)
- // u32 skipped
- // PluralName 0x1 String16L (variable, padded to 4) skipped
- // ItemCapacity 0x2 1 byte skipped
- // ContainerCap 0x4 1 byte skipped
- // AmmoType 0x100 u16 skipped
- // Value 0x8 u32 skipped
- // Useability 0x10 u32 KEPT
- // UseRadius 0x20 f32 KEPT
+ // Wire order (verified against ACE WorldObject_Networking.cs):
+ // bit field width
+ // --------- ------------------ -----
+ // 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
+ // 0x00000001 PluralName String16L (skip)
+ // 0x00000002 ItemsCapacity u8 (skip)
+ // 0x00000004 ContainersCapacity u8 (skip)
+ // 0x00000100 AmmoType u16 (skip)
+ // 0x00000008 Value u32 (skip)
+ // 0x00000010 Usable u32 KEPT
+ // 0x00000020 UseRadius f32 KEPT
+ // 0x00080000 TargetType u32 (skip)
+ // 0x00000080 UiEffects u32 (skip)
+ // 0x00000200 CombatUse sbyte/1 byte (skip)
+ // 0x00000400 Structure u16 (skip)
+ // 0x00000800 MaxStructure u16 (skip)
+ // 0x00001000 StackSize u16 (skip)
+ // 0x00002000 MaxStackSize u16 (skip)
+ // 0x00004000 Container u32 (skip)
+ // 0x00008000 Wielder u32 (skip)
+ // 0x00010000 ValidLocations u32 (skip)
+ // 0x00020000 CurrentlyWieldedLocation u32 (skip)
+ // 0x00040000 Priority u32 (skip)
+ // 0x00100000 RadarBlipColor u8 (skip)
+ // 0x00800000 RadarBehavior u8 (skip)
+ // 0x08000000 PScript u16 (skip)
+ // 0x01000000 Workmanship f32 (skip)
+ // 0x00200000 Burden u16 (skip)
+ // 0x00400000 Spell u16 (skip)
+ // 0x02000000 HouseOwner u32 (skip)
+ // 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length)
+ // 0x20000000 HookItemTypes u32 (skip)
+ // 0x00000040 Monarch u32 (skip)
+ // 0x10000000 HookType u16 (skip)
+ // 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
+ // weenieFlags2 bit 0x01:
+ // IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
//
- // Wrapped in try/catch — if a malformed entity truncates the
- // tail we still return the prefix fields. Most spawned entities
- // either have all of these or none of them.
+ // The entire walk is inside try/catch. A truncated packet degrades
+ // gracefully: whatever was parsed before the throw is kept, and
+ // IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is
+ // SAFE because IconComposer early-returns on id==0 per layer.
uint? useability = null;
float? useRadius = null;
+ uint iconOverlayId = 0;
+ uint iconUnderlayId = 0;
+ uint weenieFlags2 = 0;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@@ -571,20 +613,25 @@ public static class CreateObject
// Earlier code had this as 0x80000000 — wrong bit, so the
// weenieFlags2 4-byte skip never fired for entities that
// actually had it set, corrupting downstream optional-tail
- // offsets. Now correct.
+ // offsets. Now correct. We CAPTURE weenieFlags2 now (instead
+ // of skipping) so we can gate IconUnderlay from bit 0x01.
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
- if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
+ if (hasSecondHeader)
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
+ weenieFlags2 = ReadU32(body, ref pos);
+ }
if ((weenieFlags & 0x00000001u) != 0) // PluralName
_ = ReadString16L(body, ref pos);
- if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
+ if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
pos += 1;
}
- if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
+ if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
pos += 1;
@@ -599,7 +646,7 @@ public static class CreateObject
if (body.Length - pos < 4) throw new FormatException("trunc Value");
pos += 4;
}
- if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
+ if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = ReadU32(body, ref pos);
@@ -610,6 +657,147 @@ public static class CreateObject
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
+
+ // ---- Extended walk: fields after UseRadius through IconOverlay ----
+ // Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17).
+
+ if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
+ {
+ if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
+ pos += 1;
+ }
+ if ((weenieFlags & 0x00000400u) != 0) // Structure u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc Structure");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x00004000u) != 0) // Container u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc Container");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00040000u) != 0) // Priority u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc Priority");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
+ {
+ if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
+ pos += 1;
+ }
+ if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
+ {
+ if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
+ pos += 1;
+ }
+ if ((weenieFlags & 0x08000000u) != 0) // PScript u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc PScript");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00200000u) != 0) // Burden u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc Burden");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x00400000u) != 0) // Spell u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc Spell");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB)
+ {
+ // Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write:
+ // u32 Version, u32 OpenStatus, u32 MonarchId,
+ // u16 count, u16 numBuckets, then count × (u32 guid + u32 value).
+ // Fixed header = 12 bytes; PackableHashTable header = 4 bytes.
+ // Total = 16 + count * 8.
+ if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header");
+ // Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes
+ pos += 12;
+ ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
+ pos += 2; // count u16
+ pos += 2; // numBuckets u16
+ int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value
+ if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
+ pos += entryBytes;
+ }
+ if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x00000040u) != 0) // Monarch u32
+ {
+ if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
+ pos += 4;
+ }
+ if ((weenieFlags & 0x10000000u) != 0) // HookType u16
+ {
+ if (body.Length - pos < 2) throw new FormatException("trunc HookType");
+ pos += 2;
+ }
+ if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
+ {
+ iconOverlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
+ }
+ // IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags.
+ // weenieFlags2 is only present when hasSecondHeader (captured above).
+ if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
+ {
+ iconUnderlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
+ }
}
catch { /* truncated weenie tail — keep whatever we got. */ }
@@ -619,7 +807,8 @@ public static class CreateObject
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,
- Useability: useability, UseRadius: useRadius);
+ Useability: useability, UseRadius: useRadius,
+ IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 2f07ede3..d263f0e7 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -82,7 +82,13 @@ public sealed class WorldSession : IDisposable
uint? Useability = null,
float? UseRadius = null,
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
- uint IconId = 0);
+ uint IconId = 0,
+ // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
+ // WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
+ // (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
+ // Zero when the server did not send the field (common for most entities).
+ uint IconOverlayId = 0,
+ uint IconUnderlayId = 0);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
@@ -719,7 +725,9 @@ public sealed class WorldSession : IDisposable
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius,
- parsed.Value.IconId));
+ parsed.Value.IconId,
+ parsed.Value.IconOverlayId,
+ parsed.Value.IconUnderlayId));
}
}
else if (op == DeleteObject.Opcode)
diff --git a/src/AcDream.Core/Items/ItemRepository.cs b/src/AcDream.Core/Items/ItemRepository.cs
index a993f336..b92dd1ce 100644
--- a/src/AcDream.Core/Items/ItemRepository.cs
+++ b/src/AcDream.Core/Items/ItemRepository.cs
@@ -144,13 +144,22 @@ public sealed class ItemRepository
/// Raises ItemPropertiesUpdated whenever the item is found (matching the
/// UpdateProperties convention — it fires on found regardless of whether a field
/// actually changed) so bound widgets (the toolbar) re-render.
+ ///
+ /// D.5.1 (2026-06-17): also accepts and
+ /// from the extended WeenieHeader tail. Both
+ /// default to 0 (not sent by server). IconComposer.GetIcon already composites
+ /// underlay/base/overlay in the correct retail layer order and early-returns on 0.
+ ///
///
- public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type)
+ public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type,
+ uint iconOverlayId = 0, uint iconUnderlayId = 0)
{
if (!_items.TryGetValue(objectId, out var item)) return false;
if (iconId != 0) item.IconId = iconId;
if (!string.IsNullOrEmpty(name)) item.Name = name;
if (type != default) item.Type = type;
+ if (iconOverlayId != 0) item.IconOverlayId = iconOverlayId;
+ if (iconUnderlayId != 0) item.IconUnderlayId = iconUnderlayId;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index 8a98d62f..b58c6fe3 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -179,6 +179,122 @@ public sealed class CreateObjectTests
Assert.Equal(0x06001234u, parsed!.Value.IconId);
}
+ // -----------------------------------------------------------------------
+ // D.5.1 (2026-06-17): extended WeenieHeader optional-tail walk — the parser
+ // now continues past UseRadius through ALL intervening fields to reach
+ // IconOverlay (weenieFlags bit 0x40000000) and IconUnderlay (weenieFlags2
+ // bit 0x01, present when objDescFlags bit 0x04000000 is set).
+ //
+ // Two tests:
+ // 1. WithIconOverlay — sets only the IconOverlay bit + the minimum
+ // intervening fields (none in this minimal body, so weenieFlags only has
+ // 0x40000000). Verifies the parse walks to IconOverlay and captures it.
+ // 2. WithIconOverlayAndUnderlay — sets IconOverlay + the IncludesSecondHeader
+ // objDescFlag + weenieFlags2 bit 0x01, writes both ids, asserts both are
+ // captured.
+ // 3. NoOverlayBits_CommonCase — weenieFlags=0, verifies the extended walk
+ // produces no overlay (regression guard for the common spawn path).
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void TryParse_IconOverlay_CapturedFromExtendedTail()
+ {
+ // Only IconOverlay (0x40000000) bit set in weenieFlags. No intervening
+ // optional fields, so the extended tail immediately reads the overlay id.
+ // ACE WritePackedDwordOfKnownType strips the 0x06000000 prefix before
+ // packing; the reader ORs it back in.
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x5000000Au,
+ name: "EnchantedSword",
+ itemType: (uint)ItemType.MeleeWeapon,
+ weenieFlags: 0x40000000u, // IconOverlay
+ iconOverlayId: 0x1ABCu); // will be read back as 0x06001ABC
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x06001ABCu, parsed!.Value.IconOverlayId);
+ Assert.Equal(0u, parsed.Value.IconUnderlayId);
+ }
+
+ [Fact]
+ public void TryParse_IconOverlayAndUnderlay_BothCaptured()
+ {
+ // IncludesSecondHeader in objDescFlags (0x04000000) makes the parser read
+ // weenieFlags2. weenieFlags2 bit 0x01 (IconUnderlay) triggers the underlay
+ // read. Both overlay + underlay are captured.
+ // objectDescriptionFlags: 0x04000000 = IncludesSecondHeader
+ // weenieFlags: 0x40000000 = IconOverlay
+ // weenieFlags2: 0x00000001 = IconUnderlay
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x5000000Bu,
+ name: "MagicRing",
+ itemType: (uint)ItemType.Jewelry,
+ objectDescriptionFlags: 0x04000000u,
+ weenieFlags: 0x40000000u,
+ weenieFlags2: 0x00000001u,
+ iconOverlayId: 0x5678u, // → 0x06005678
+ iconUnderlayId: 0x9ABCu); // → 0x06009ABC
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x06005678u, parsed!.Value.IconOverlayId);
+ Assert.Equal(0x06009ABCu, parsed.Value.IconUnderlayId);
+ }
+
+ [Fact]
+ public void TryParse_NoOverlayBits_CommonCase_OverlaysStayZero()
+ {
+ // Regression guard: most spawned entities (creatures, scenery, players)
+ // have weenieFlags=0 and no second-header. The extended walk must not
+ // corrupt existing parsed fields and must leave overlay ids at zero.
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x5000000Cu,
+ name: "CommonDrudge",
+ itemType: (uint)ItemType.Creature,
+ weenieFlags: 0u);
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal("CommonDrudge", parsed!.Value.Name);
+ Assert.Equal(0u, parsed.Value.IconOverlayId);
+ Assert.Equal(0u, parsed.Value.IconUnderlayId);
+ Assert.Null(parsed.Value.Useability);
+ }
+
+ [Fact]
+ public void TryParse_IntermediateFieldsBeforeIconOverlay_SkippedCorrectly()
+ {
+ // Verifies the cursor arithmetic for fields between UseRadius and
+ // IconOverlay. This body sets several intermediate bits (Structure u16,
+ // MaxStructure u16, StackSize u16, Burden u16) plus IconOverlay.
+ // If any skip is wrong, the parser reads the wrong bytes as the
+ // overlay id or throws, both of which the assert would catch.
+ // 0x00000400 = Structure (u16)
+ // 0x00000800 = MaxStructure (u16)
+ // 0x00001000 = StackSize (u16)
+ // 0x00200000 = Burden (u16)
+ // 0x40000000 = IconOverlay
+ const uint flags = 0x40000000u | 0x00200000u | 0x00001000u | 0x00000800u | 0x00000400u;
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x5000000Du,
+ name: "FancySword",
+ itemType: (uint)ItemType.MeleeWeapon,
+ weenieFlags: flags,
+ structure: 50,
+ maxStructure: 100,
+ stackSize: 1,
+ burden: 300,
+ iconOverlayId: 0x2222u); // → 0x06002222
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x06002222u, parsed!.Value.IconOverlayId);
+ }
+
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
@@ -186,10 +302,18 @@ public sealed class CreateObjectTests
uint physicsState = 0,
uint objectDescriptionFlags = 0,
uint weenieFlags = 0,
+ uint weenieFlags2 = 0,
uint iconId = 0,
uint? value = null,
uint? useability = null,
- float? useRadius = null)
+ float? useRadius = null,
+ uint iconOverlayId = 0,
+ uint iconUnderlayId = 0,
+ // intermediate fields for cursor-arithmetic test
+ ushort? structure = null,
+ ushort? maxStructure = null,
+ ushort? stackSize = null,
+ ushort? burden = null)
{
var bytes = new List();
WriteU32(bytes, CreateObject.Opcode);
@@ -217,19 +341,67 @@ public sealed class CreateObjectTests
WriteU32(bytes, objectDescriptionFlags);
Align4(bytes);
- // Optional WeenieHeader tail (2026-05-15) — same order as ACE
- // WorldObject_Networking.cs:87-114. Each field is written only when
+ // IncludesSecondHeader → weenieFlags2 written immediately after the align,
+ // before any other optional tail field (ACE WorldObject_Networking.cs:84-85).
+ if ((objectDescriptionFlags & 0x04000000u) != 0)
+ WriteU32(bytes, weenieFlags2);
+
+ // Optional WeenieHeader tail — same order as ACE
+ // WorldObject_Networking.cs:87-206. Each field is written only when
// its weenieFlags bit is set, matching the parser's walker exactly.
- if ((weenieFlags & 0x00000008u) != 0) // Value u32
- WriteU32(bytes, value ?? 0u);
- if ((weenieFlags & 0x00000010u) != 0) // Useability u32
- WriteU32(bytes, useability ?? 0u);
- if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32
+ // Fields not parameterized above default to 0.
+ if ((weenieFlags & 0x00000001u) != 0) { /* PluralName — not parameterized */ }
+ if ((weenieFlags & 0x00000002u) != 0) bytes.Add(0); // ItemsCapacity u8
+ if ((weenieFlags & 0x00000004u) != 0) bytes.Add(0); // ContainersCapacity u8
+ if ((weenieFlags & 0x00000100u) != 0) WriteU16(bytes, 0); // AmmoType u16
+ if ((weenieFlags & 0x00000008u) != 0) WriteU32(bytes, value ?? 0u); // Value u32
+ if ((weenieFlags & 0x00000010u) != 0) WriteU32(bytes, useability ?? 0u); // Usable u32
+ if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32
{
Span tmp = stackalloc byte[4];
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
bytes.AddRange(tmp.ToArray());
}
+ if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32
+ if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, 0); // UiEffects u32
+ if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
+ if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16
+ if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16
+ if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16
+ if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, 0); // MaxStackSize u16
+ if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, 0); // Container u32
+ if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, 0); // Wielder u32
+ if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, 0); // ValidLocations u32
+ if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, 0); // CurrentlyWieldedLocation u32
+ if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, 0); // Priority u32
+ if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8
+ if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8
+ if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16
+ if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
+ {
+ Span tmp = stackalloc byte[4];
+ BinaryPrimitives.WriteSingleLittleEndian(tmp, 0f);
+ bytes.AddRange(tmp.ToArray());
+ }
+ if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16
+ if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16
+ if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32
+ // HouseRestrictions (0x04000000): not parameterized (zero entries).
+ // Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries.
+ // Zero entries → 16 bytes total.
+ if ((weenieFlags & 0x04000000u) != 0)
+ {
+ WriteU32(bytes, 0x10000002u); // Version
+ WriteU32(bytes, 0u); // OpenStatus
+ WriteU32(bytes, 0u); // MonarchId
+ WriteU16(bytes, 0); // count
+ WriteU16(bytes, 768); // numBuckets (retail constant)
+ }
+ if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, 0); // HookItemTypes u32
+ if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32
+ if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16
+ if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay
+ if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay
return bytes.ToArray();
}