Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client. Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
834 lines
34 KiB
C#
834 lines
34 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);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_WeenieFlagsTargetType_ReadsTargetType()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000009u,
|
||
name: "Healing Kit",
|
||
itemType: (uint)ItemType.Misc,
|
||
weenieFlags: 0x00080000u,
|
||
targetType: (uint)ItemType.Creature);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_CombatUseFlag_CapturesByte()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x5000000Du,
|
||
name: "Sword",
|
||
itemType: (uint)ItemType.MeleeWeapon,
|
||
weenieFlags: 0x00000200u,
|
||
combatUse: 2);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((byte)2, parsed.Value.CombatUse);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_AmmoTypeFlag_CapturesValue()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x5000000Eu,
|
||
name: "Bow",
|
||
itemType: (uint)ItemType.MissileWeapon,
|
||
weenieFlags: 0x00000100u,
|
||
ammoType: 1);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((ushort)1, parsed.Value.AmmoType);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_ParentedChild_CapturesPlacementParentAndPositionSequence()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x60000002u,
|
||
name: "Bow",
|
||
itemType: (uint)ItemType.MissileWeapon,
|
||
placementId: (uint)DatReaderWriter.Enums.Placement.LeftHand,
|
||
parentGuid: 0x50000001u,
|
||
parentLocation: (uint)DatReaderWriter.Enums.ParentLocation.LeftHand,
|
||
positionSeq: 0x3456,
|
||
instanceSeq: 0x789A);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((uint)DatReaderWriter.Enums.Placement.LeftHand, parsed.Value.PlacementId);
|
||
Assert.Equal(0x50000001u, parsed.Value.ParentGuid);
|
||
Assert.Equal((uint)DatReaderWriter.Enums.ParentLocation.LeftHand, parsed.Value.ParentLocation);
|
||
Assert.Equal((ushort)0x3456, parsed.Value.PositionSequence);
|
||
Assert.Equal((ushort)0x789A, parsed.Value.InstanceSequence);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
|
||
// Absence is distinct from an explicitly transmitted zero because zero is
|
||
// the enum's undefined/default value, not proof the field was omitted.
|
||
// -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public void TryParse_NoRadarFlags_LeavesRadarFieldsNull()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x5000000Au,
|
||
name: "Unspecified Radar Object",
|
||
itemType: (uint)ItemType.Misc);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Null(parsed.Value.RadarBlipColor);
|
||
Assert.Null(parsed.Value.RadarBehavior);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_RadarFlags_CapturesBothBytesAndContinuesTailWalk()
|
||
{
|
||
const uint radarBlipColorFlag = 0x00100000u;
|
||
const uint radarBehaviorFlag = 0x00800000u;
|
||
const uint workmanshipFlag = 0x01000000u;
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x5000000Bu,
|
||
name: "Radar Portal",
|
||
itemType: (uint)ItemType.Portal,
|
||
weenieFlags: radarBlipColorFlag | radarBehaviorFlag | workmanshipFlag,
|
||
radarBlipColor: 4,
|
||
radarBehavior: 4,
|
||
workmanship: 6.25f);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((byte)4, parsed.Value.RadarBlipColor);
|
||
Assert.Equal((byte)4, parsed.Value.RadarBehavior);
|
||
Assert.Equal(6.25f, parsed.Value.Workmanship);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_RadarFlagsWithZero_PreservesExplicitDefaultValues()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x5000000Cu,
|
||
name: "Explicit Radar Defaults",
|
||
itemType: (uint)ItemType.Misc,
|
||
weenieFlags: 0x00100000u | 0x00800000u,
|
||
radarBlipColor: 0,
|
||
radarBehavior: 0);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.True(parsed.Value.RadarBlipColor.HasValue);
|
||
Assert.Equal((byte)0, parsed.Value.RadarBlipColor.Value);
|
||
Assert.True(parsed.Value.RadarBehavior.HasValue);
|
||
Assert.Equal((byte)0, parsed.Value.RadarBehavior.Value);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_WeenieClassId_Surfaced()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon,
|
||
weenieClassId: 0xABCDu);
|
||
var parsed = CreateObject.TryParse(body);
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_FullItemFields_Captured()
|
||
{
|
||
uint flags =
|
||
0x00000008u | 0x00001000u | 0x00002000u | 0x00200000u |
|
||
0x00000002u | 0x00000004u | 0x00004000u | 0x00008000u |
|
||
0x00010000u | 0x00020000u | 0x00040000u | 0x00000400u |
|
||
0x00000800u | 0x01000000u;
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container,
|
||
weenieFlags: flags,
|
||
value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42,
|
||
itemsCapacity: 24, containersCapacity: 7,
|
||
container: 0x50000099u, wielder: 0x5000009Au,
|
||
validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u,
|
||
priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f);
|
||
var parsed = CreateObject.TryParse(body);
|
||
Assert.NotNull(parsed);
|
||
var p = parsed!.Value;
|
||
Assert.Equal(250, p.Value);
|
||
Assert.Equal(7, p.StackSize);
|
||
Assert.Equal(100, p.StackSizeMax);
|
||
Assert.Equal(42, p.Burden);
|
||
Assert.Equal(24, p.ItemsCapacity);
|
||
Assert.Equal(7, p.ContainersCapacity);
|
||
Assert.Equal(0x50000099u, p.ContainerId);
|
||
Assert.Equal(0x5000009Au, p.WielderId);
|
||
Assert.Equal(0x02000000u, p.ValidLocations);
|
||
Assert.Equal(0x02000000u, p.CurrentWieldedLocation);
|
||
Assert.Equal(8u, p.Priority);
|
||
Assert.Equal(5, p.Structure);
|
||
Assert.Equal(10, p.MaxStructure);
|
||
Assert.Equal(7.5f, p.Workmanship);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_MovementSequence_SurfacedFromTimestampBlock()
|
||
{
|
||
// L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block
|
||
// is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds
|
||
// PhysicsTimestampGate's MOVEMENT_TS so post-spawn UpdateMotion events
|
||
// are judged against the entity's live sequence, not zero.
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000030u, name: "Runner", itemType: 0x10u,
|
||
movementSeq: 0x9000);
|
||
var parsed = CreateObject.TryParse(body);
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal((ushort)0x9000, parsed!.Value.MovementSequence);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_MidTailFieldsSet_StillReachesIconOverlay()
|
||
{
|
||
uint flags = 0x00001000u | 0x00004000u | 0x40000000u;
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry,
|
||
weenieFlags: flags, stackSize: 1, container: 0x500000F0u,
|
||
iconOverlayId: 0x4321u);
|
||
var parsed = CreateObject.TryParse(body);
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId);
|
||
Assert.Equal(0x500000F0u, parsed.Value.ContainerId);
|
||
}
|
||
|
||
[Fact]
|
||
public void WeenieHeader_pluralName_isPreserved()
|
||
{
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000023u,
|
||
name: "Pyreal Scarab",
|
||
itemType: (uint)ItemType.Misc,
|
||
weenieFlags: 0x00000001u | 0x00001000u,
|
||
stackSize: 2,
|
||
pluralName: "Pyreal Scarabs");
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal("Pyreal Scarabs", parsed.Value.PluralName);
|
||
}
|
||
|
||
[Fact]
|
||
public void WeenieHeader_secondHeaderPetOwner_isPreservedAfterPrecedingFields()
|
||
{
|
||
// PublicWeenieDesc::UnPack @ 0x005AD7AC..0x005AD7F8:
|
||
// MaterialType, CooldownId, CooldownDuration, then PetOwner.
|
||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||
guid: 0x50000024u,
|
||
name: "Combat Pet",
|
||
itemType: (uint)ItemType.Creature,
|
||
objectDescriptionFlags: 0x04000000u,
|
||
weenieFlags: 0x80000000u,
|
||
weenieFlags2: 0x0000000Eu,
|
||
materialType: 7u,
|
||
cooldownId: 11u,
|
||
cooldownDuration: 12.5,
|
||
petOwnerId: 0x50000001u);
|
||
|
||
var parsed = CreateObject.TryParse(body);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
|
||
}
|
||
|
||
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? targetType = 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,
|
||
uint weenieClassId = 0x1234,
|
||
uint? maxStackSize = null,
|
||
byte? itemsCapacity = null,
|
||
byte? containersCapacity = null,
|
||
uint? container = null,
|
||
uint? wielder = null,
|
||
uint? validLocations = null,
|
||
uint? currentWieldedLocation = null,
|
||
uint? priority = null,
|
||
float? workmanship = null,
|
||
string? pluralName = null,
|
||
byte? radarBlipColor = null,
|
||
byte? radarBehavior = null,
|
||
byte? combatUse = null,
|
||
ushort? ammoType = null,
|
||
ushort movementSeq = 0,
|
||
uint? placementId = null,
|
||
uint? parentGuid = null,
|
||
uint? parentLocation = null,
|
||
ushort positionSeq = 0,
|
||
ushort instanceSeq = 0,
|
||
uint materialType = 0,
|
||
uint cooldownId = 0,
|
||
double cooldownDuration = 0,
|
||
uint petOwnerId = 0)
|
||
{
|
||
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: optional placement + parent bootstrap, then 9 seq stamps
|
||
// (PhysicsTimeStamp enum order; index 1 = ObjectMovement).
|
||
uint physicsFlags = 0;
|
||
if (placementId.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.AnimationFrame;
|
||
if (parentGuid.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.Parent;
|
||
WriteU32(bytes, physicsFlags);
|
||
WriteU32(bytes, physicsState);
|
||
if (placementId.HasValue) WriteU32(bytes, placementId.Value);
|
||
if (parentGuid.HasValue)
|
||
{
|
||
WriteU32(bytes, parentGuid.Value);
|
||
WriteU32(bytes, parentLocation ?? 0u);
|
||
}
|
||
for (int i = 0; i < 9; i++)
|
||
WriteU16(bytes, i switch
|
||
{
|
||
0 => positionSeq,
|
||
1 => movementSeq,
|
||
8 => instanceSeq,
|
||
_ => 0,
|
||
});
|
||
Align4(bytes);
|
||
|
||
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
|
||
WriteU32(bytes, weenieFlags); // weenieFlags
|
||
WriteString16L(bytes, name);
|
||
WritePackedDword(bytes, weenieClassId); // 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) WriteString16L(bytes, pluralName ?? "");
|
||
if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8
|
||
if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8
|
||
if ((weenieFlags & 0x00000100u) != 0) WriteU16(bytes, ammoType ?? 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, targetType ?? 0u); // TargetType u32
|
||
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
|
||
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(combatUse ?? 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, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16
|
||
if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32
|
||
if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32
|
||
if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32
|
||
if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32
|
||
if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32
|
||
if ((weenieFlags & 0x00100000u) != 0) bytes.Add(radarBlipColor ?? 0); // RadarBlipColor u8
|
||
if ((weenieFlags & 0x00800000u) != 0) bytes.Add(radarBehavior ?? 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, workmanship ?? 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
|
||
if ((weenieFlags & 0x80000000u) != 0) WriteU32(bytes, materialType); // MaterialType
|
||
if ((weenieFlags2 & 0x00000002u) != 0) WriteU32(bytes, cooldownId); // CooldownId
|
||
if ((weenieFlags2 & 0x00000004u) != 0)
|
||
{
|
||
Span<byte> tmp = stackalloc byte[8];
|
||
BinaryPrimitives.WriteDoubleLittleEndian(tmp, cooldownDuration);
|
||
bytes.AddRange(tmp.ToArray());
|
||
}
|
||
if ((weenieFlags2 & 0x00000008u) != 0) WriteU32(bytes, petOwnerId); // PetOwner
|
||
Align4(bytes);
|
||
|
||
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);
|
||
}
|
||
}
|