feat(D.5.1): parse item IconOverlay/IconUnderlay from CreateObject -> faithful icon overlay layer

The CreateObject optional-tail walker previously stopped at UseRadius (~20 fields
before IconOverlay). This left ItemInstance.IconOverlayId/IconUnderlayId always 0,
so IconComposer's underlay/overlay layers were never drawn on toolbar icons.

Exact field order verified against ACE WorldObject_Networking.cs:87-219 (the
serializer is the authority; acdream connects to a local ACE server):
  UseRadius → TargetType(u32) → UiEffects(u32) → CombatUse(sbyte) →
  Structure(u16) → MaxStructure(u16) → StackSize(u16) → MaxStackSize(u16) →
  Container(u32) → Wielder(u32) → ValidLocations(u32) →
  CurrentlyWieldedLocation(u32) → Priority(u32) → RadarBlipColor(u8) →
  RadarBehavior(u8) → PScript(u16) → Workmanship(f32) → Burden(u16) →
  Spell(u16) → HouseOwner(u32) → HouseRestrictions(variable RestrictionDB) →
  HookItemTypes(u32) → Monarch(u32) → HookType(u16) →
  IconOverlay(PackedDwordKnownType) ← CAPTURE →
  IconUnderlay from weenieFlags2 bit 0x01 ← CAPTURE

RestrictionDB handled correctly: Version(u32) + OpenStatus(u32) + MonarchId(u32)
+ count(u16) + numBuckets(u16) + count×8 bytes entries. Length-aware skip, not a
fixed constant.

weenieFlags2 is now CAPTURED (not skipped) when IncludesSecondHeader
(objDescFlags bit 0x04000000) is set, so the IconUnderlay bit can be tested.

The entire extended walk is inside try/catch: truncated packets degrade to
IconOverlayId=0 / IconUnderlayId=0 (no overlay drawn), never corrupting.

Threading: CreateObject.Parsed → WorldSession.EntitySpawn → GameWindow
OnLiveEntitySpawned → Items.EnrichItem — both ids thread through all three
seams. EnrichItem extended with optional iconOverlayId + iconUnderlayId params
(defaulted 0, backward-compatible).

No change to IconComposer or ToolbarController (they already consume the ids).

Tests: 4 new CreateObject tests (IconOverlay only, overlay+underlay, no-overlay
regression, intermediate-fields cursor arithmetic). Full suite: 0 failures,
2636 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-17 14:34:47 +02:00
parent a7cad5566b
commit 8a42066192
5 changed files with 421 additions and 39 deletions

View file

@ -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<byte>();
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<byte> 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<byte> 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();
}