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

@ -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);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -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 &amp; 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).

View file

@ -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);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? 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)