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:
parent
a7cad5566b
commit
8a42066192
5 changed files with 421 additions and 39 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue