acdream/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Erik 8df0b64676 feat(D.5.2): capture UiEffects from CreateObject weenie header
Previously, weenieFlags bit 0x80 (UiEffects) was read + discarded with
`pos += 4`. Now it is captured into `uiEffects` and surfaced as
`Parsed.UiEffects` — the sole wire path for the effect bitfield since
PropertyInt.UiEffects (18) has no [AssessmentProperty] and never appears
in appraise responses.

Test builder gains `uint uiEffects = 0` param; write line updated to use
it. Three new parse tests: UiEffects_Captured, UiEffectsThenIconOverlay
(cursor-arithmetic regression), and NoUiEffectsBit_LeavesUiEffectsZero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:28:31 +02:00

532 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
[Fact]
public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured()
{
// Verifies that the variable-length RestrictionDB skip (weenieFlags bit
// 0x04000000) lands the cursor at the correct position so that
// IconOverlay (bit 0x40000000) immediately after it is still captured.
//
// Wire layout per ACE RestrictionDB (16 bytes, zero entries):
// Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes
// count(u16) + numBuckets(u16) = 4 bytes
// entries: count(0) × 8 = 0 bytes
// total = 16 bytes
//
// Also exercises the IncludesSecondHeader / IconUnderlay path so that
// all three optional-tail branches that follow HouseOwner are covered
// in a single cursor sweep.
//
// weenieFlags: 0x04000000 (HouseRestrictions) | 0x40000000 (IconOverlay)
// objectDescriptionFlags: 0x04000000 (IncludesSecondHeader → weenieFlags2 present)
// weenieFlags2: 0x00000001 (IconUnderlay)
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x5000000Eu,
name: "HousePortal",
itemType: (uint)ItemType.Portal,
objectDescriptionFlags: 0x04000000u, // IncludesSecondHeader
weenieFlags: 0x04000000u | 0x40000000u, // HouseRestrictions + IconOverlay
weenieFlags2: 0x00000001u, // IconUnderlay
iconOverlayId: 0x3333u, // → 0x06003333
iconUnderlayId: 0x4444u); // → 0x06004444
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId);
Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId);
}
// -----------------------------------------------------------------------
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured
// instead of skipped. Drives the icon's effect-overlay recolor.
// -----------------------------------------------------------------------
[Fact]
public void TryParse_UiEffects_Captured()
{
// weenieFlags 0x80 = UiEffects; value 0x1 = Magical.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster,
weenieFlags: 0x80u, uiEffects: 0x1u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x1u, parsed!.Value.UiEffects);
}
[Fact]
public void TryParse_UiEffectsThenIconOverlay_BothCaptured()
{
// Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon,
weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x4u, parsed!.Value.UiEffects);
Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId);
}
[Fact]
public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0u, parsed!.Value.UiEffects);
}
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 uiEffects = 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, uiEffects); // 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);
}
}