acdream/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Erik 8a42066192 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>
2026-06-17 14:34:47 +02:00

448 lines
19 KiB
C#

using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class CreateObjectTests
{
[Fact]
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000002u,
name: "Drudge",
itemType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000002u, parsed.Value.Guid);
Assert.Equal("Drudge", parsed.Value.Name);
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
}
// -----------------------------------------------------------------------
// Commit A of 2026-04-29 live-entity collision port:
// PhysicsState (post-flags u32) + ObjectDescriptionFlags (PWD bitfield)
// must be surfaced for downstream collision registration.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_PhysicsState_Parsed()
{
// ETHEREAL_PS = 0x4 + IGNORE_COLLISIONS_PS = 0x10 → 0x14
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000003u, name: "GhostNpc",
itemType: (uint)ItemType.Creature,
physicsState: 0x14u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x14u, parsed!.Value.PhysicsState);
}
[Fact]
public void TryParse_ObjectDescriptionFlags_PlayerKillerBitsSurface()
{
// BF_PLAYER (0x8) | BF_PLAYER_KILLER (0x20) → a PK player.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000004u, name: "+PkPlayer",
itemType: (uint)ItemType.Creature,
objectDescriptionFlags: 0x8u | 0x20u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x28u, parsed!.Value.ObjectDescriptionFlags);
}
[Fact]
public void TryParse_ObjectDescriptionFlags_PkLiteBit()
{
// BF_PLAYER (0x8) | BF_PKLITE_PKSTATUS (0x2000000) → a PK-lite player.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000005u, name: "+PklPlayer",
itemType: (uint)ItemType.Creature,
objectDescriptionFlags: 0x8u | 0x2000000u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x2000008u, parsed!.Value.ObjectDescriptionFlags);
}
// -----------------------------------------------------------------------
// 2026-05-15: WeenieHeader optional-tail walker landed for Useability +
// UseRadius (acclient.h ITEM_USEABLE enum at line 6478). The R-key Use
// gate consumes Useability; signs without USEABLE_REMOTE (0x20) silently
// ignore Use.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_NoWeenieFlags_LeavesUseabilityNull()
{
// Sign-like entity: weenieFlags=0 (no optional fields).
// Useability stays null (parser walked past nothing).
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000006u, name: "Holtburg Sign",
itemType: 0x8000u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed!.Value.Useability);
Assert.Null(parsed.Value.UseRadius);
}
[Fact]
public void TryParse_WeenieFlagsUsable_ReadsUseability()
{
// Useable NPC: weenieFlags has bit 0x10 set, body carries
// ITEM_USEABLE = USEABLE_REMOTE (0x20).
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000007u, name: "Tirenia",
itemType: (uint)ItemType.Creature,
weenieFlags: 0x10u,
useability: 0x20u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x20u, parsed!.Value.Useability);
}
[Fact]
public void TryParse_WeenieFlagsUsable_ReadsUseableNoValue()
{
// Holtburg sign case (observed 2026-05-16): ACE sends
// weenieFlags=0x10 + Useability=USEABLE_NO (0x01) for signs.
// The parser must read this verbatim — downstream code
// distinguishes USEABLE_NO from USEABLE_REMOTE for the
// pickup vs use gate.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x7A9B3001u, name: "Holtburg",
itemType: 0x80u, // Misc
weenieFlags: 0x10u,
useability: 0x01u); // USEABLE_NO
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x01u, parsed!.Value.Useability);
}
[Fact]
public void TryParse_WeenieFlagsValueAndUsableAndUseRadius_AllReadInOrder()
{
// Verify the walker skips Value (bit 0x8, 4 bytes) BEFORE reading
// Useability (bit 0x10) and UseRadius (bit 0x20). Wire order in
// ACE WorldObject_Networking.cs:99-106 is Value, Useable, UseRadius.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000008u, name: "PriceyDoor",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x8u | 0x10u | 0x20u,
value: 0x12345678u,
useability: 0x20u,
useRadius: 2.5f);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x20u, parsed!.Value.Useability);
Assert.NotNull(parsed.Value.UseRadius);
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
}
// -----------------------------------------------------------------------
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
// action bar / equipment UI can read icon dat ids from spawn messages.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_IconId_Surfaced()
{
// Icon dat id 0x06001234: the wire writer strips the 0x06000000 prefix
// before packing (WritePackedDwordOfKnownType strips it), so we write
// 0x1234 as the packed value and expect 0x06001234 back.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000009u,
name: "SwordIcon",
itemType: (uint)ItemType.MeleeWeapon,
iconId: 0x1234u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
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,
uint itemType,
uint physicsState = 0,
uint objectDescriptionFlags = 0,
uint weenieFlags = 0,
uint weenieFlags2 = 0,
uint iconId = 0,
uint? value = null,
uint? useability = 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);
WriteU32(bytes, guid);
// ModelData header: marker, subpalette count, texture count, animpart count.
bytes.Add(0x11);
bytes.Add(0);
bytes.Add(0);
bytes.Add(0);
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps.
WriteU32(bytes, 0);
WriteU32(bytes, physicsState);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
WriteU32(bytes, weenieFlags); // weenieFlags
WriteString16L(bytes, name);
WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, iconId); // IconId via known-type writer (prefix stripped by ACE writer)
WriteU32(bytes, itemType);
WriteU32(bytes, objectDescriptionFlags);
Align4(bytes);
// 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.
// 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();
}
private static void WriteU32(List<byte> bytes, uint value)
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WriteU16(List<byte> bytes, ushort value)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WritePackedDword(List<byte> bytes, uint value)
{
if (value <= 0x7FFF)
{
WriteU16(bytes, (ushort)value);
return;
}
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
WriteU16(bytes, (ushort)(value & 0xFFFF));
}
private static void WriteString16L(List<byte> bytes, string value)
{
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(bytes, checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4(bytes);
}
private static void Align4(List<byte> bytes)
{
while ((bytes.Count & 3) != 0)
bytes.Add(0);
}
}