acdream/src/AcDream.Core.Net/Messages/PublicWeenieDescBody.cs
Erik 7bd4388b6b
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
refactor(net): Slice 5.0 — extract the PublicWeenieDesc body parser for shared use (behavior-preserving)
The WeenieHeader fixed prefix plus the ~300-line conditional
optional-tail cascade moves verbatim from CreateObject.TryParse into
PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs), so Slice 5.1's
vendor-item parser can share it instead of duplicating it — each shop
item on the wire is a full CreateObject-style PublicWeenieDesc
(research doc §A.2). Same field order, same nested try/catch swallow
shape, same truncation messages; CreateObject composes its Parsed from
the returned record. Zero test files changed; Core.Net tests 764/0/0
unchanged; clean-room complete solution 11,271 passed / 4 skipped / 0
failed.

Per the Slice 5 contract (decision 2): extraction FIRST, as its own
bisectable commit, before any vendor code exists to call it.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 14:41:09 +02:00

510 lines
25 KiB
C#

using System.Buffers.Binary;
using System.Collections.Generic;
using AcDream.Core.Items;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// The result of parsing a retail <c>PublicWeenieDesc</c> body: the fixed
/// weenieFlags/name/wcid/icon/itemType/objDescFlags prefix, plus every
/// conditional optional-tail field, in exact wire order.
///
/// <para>
/// This is the SAME parse retail's <c>PublicWeenieDesc::UnPack</c> performs
/// (and ACE's <c>WorldObject_Networking.SerializeCreateObject</c> writes) at
/// two distinct wire sites: the <c>CreateObject</c> GameMessage (0xF745,
/// where this body follows Guid + ModelData + PhysicsData), and the vendor
/// <c>ApproachVendor</c> item list (GameEvent 0x0062, where — per ACE's
/// <c>gamedataonly</c> serialization — this body follows only the packed
/// stack-size dword and the item's Guid; there is no ModelData/PhysicsData
/// at all for a vendor's listed items). See
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §A.2.
/// </para>
///
/// <para>
/// Extracted from <see cref="CreateObject.TryParse"/> (Slice 5.0,
/// <c>docs/plans/2026-07-23-world-interaction-completion.md</c> Slice 5
/// decision 2) so the Slice 5.1 vendor item-list parser can reuse this exact
/// ~300-line conditional field cascade instead of duplicating it.
/// Extraction only — the field set, wire order, and truncation/failure
/// behavior are unchanged from the original inline code.
/// </para>
/// </summary>
public readonly record struct PublicWeenieDescBody(
string? Name = null,
uint? ItemType = null,
uint? ObjectDescriptionFlags = null,
uint IconId = 0,
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? HookItemTypes = null,
uint? HookType = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null,
uint? Useability = null,
float? UseRadius = null,
uint? TargetType = null,
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
uint UiEffects = 0,
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null,
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null,
uint? MaterialType = null,
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// Parses the shared <see cref="PublicWeenieDescBody"/> wire shape. See the
/// type doc above for the two call sites and the extraction rationale.
/// </summary>
public static class PublicWeenieDescParser
{
/// <summary>
/// Parse a PublicWeenieDesc body starting at <paramref name="pos"/>.
/// Never throws: both the fixed-prefix read and the optional-tail walk
/// are wrapped in their own try/catch (matching the pre-extraction
/// inline code exactly), so a truncated body degrades gracefully —
/// whatever fields parsed before the truncation are kept, and the
/// cursor position reflects exactly how far the original inline
/// parser would have advanced.
/// </summary>
public static PublicWeenieDescBody Parse(ReadOnlySpan<byte> body, ref int pos)
{
// --- WeenieHeader: read the fixed prefix fields we need. ---
// ACE WorldObject_Networking.SerializeCreateObject writes:
// weenieFlags, Name, WeenieClassId(PackedDword),
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
// ObjectDescriptionFlags, align.
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
string? pluralName = null;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
int? wStackSize = null;
int? wMaxStackSize = null;
int? wBurden = null;
int? wItemsCapacity = null;
int? wContainersCapacity = null;
uint? wHookItemTypes = null;
uint? wHookType = null;
uint? wContainerId = null;
uint? wWielderId = null;
uint? wValidLocations = null;
uint? wCurrentWieldedLocation = null;
uint? wPriority = null;
int? wStructure = null;
int? wMaxStructure = null;
float? wWorkmanship = null;
uint? objectDescriptionFlags = null;
if (body.Length - pos >= 4)
{
weenieFlags = CreateObject.ReadU32(body, ref pos);
try
{
name = CreateObject.ReadString16L(body, ref pos);
weenieClassId = CreateObject.ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
iconId = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.IconTypePrefix);
if (body.Length - pos >= 4)
itemType = CreateObject.ReadU32(body, ref pos);
if (body.Length - pos >= 4)
{
// ObjectDescriptionFlags = retail PWD._bitfield
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
// acclient_2013_pseudo_c.txt:406898-406918 read for
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
// discarded; now surfaced for the PvP collision rule.
objectDescriptionFlags = CreateObject.ReadU32(body, ref pos);
}
CreateObject.AlignTo4(ref pos);
}
catch { /* truncated name — partial result is still useful */ }
}
// --- WeenieHeader optional tail: walk every conditional field
// in EXACT ACE write order (WorldObject_Networking.cs:87-219)
// so the cursor reaches IconOverlay + IconUnderlay.
//
// 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.
//
// Wire order (verified against ACE WorldObject_Networking.cs):
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
// 0x00000001 PluralName String16L CAPTURE
// 0x00000002 ItemsCapacity s8 -> int CAPTURE
// 0x00000004 ContainersCapacity s8 -> int CAPTURE
// 0x00000100 AmmoType u16 (skip)
// 0x00000008 Value u32 (skip)
// 0x00000010 Usable u32 KEPT
// 0x00000020 UseRadius f32 KEPT
// 0x00080000 TargetType u32 (skip)
// 0x00000080 UiEffects u32 CAPTURE (D.5.2)
// 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 CAPTURE
// 0x00800000 RadarBehavior u8 CAPTURE
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
// 0x00400000 Spell u16 (skip)
// 0x02000000 HouseOwner u32 CAPTURE (AP-129)
// 0x04000000 HouseRestrictions RestrictionDB CAPTURE (AP-129, variable-length)
// 0x20000000 HookItemTypes u32 CAPTURE
// 0x00000040 Monarch u32 CAPTURE (AP-129)
// 0x10000000 HookType u16 CAPTURE
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01:
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
// 0x80000000 MaterialType u32 CAPTURE
// weenieFlags2 bit 0x02:
// CooldownId u32 CAPTURE
// weenieFlags2 bit 0x04:
// CooldownDuration f64 CAPTURE
// weenieFlags2 bit 0x08:
// PetOwner u32 CAPTURE
//
// 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? targetType = null;
byte? radarBlipColor = null;
byte? radarBehavior = null;
byte? combatUse = null;
ushort? ammoType = null;
uint? spellId = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
uint? petOwnerId = null;
uint? cooldownId = null;
double? cooldownDuration = null;
uint? materialType = null;
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields, previously skipped. HouseOwner/Monarch are plain u32
// ids; HouseRestrictions is the full RestrictionDB (open flag + allegiance
// monarch + guest table) — see AcDream.Core.Items.HouseRestrictionRecord.
uint? houseOwnerId = null;
uint? monarchId = null;
HouseRestrictionRecord? restrictions = null;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
// (ACE ObjectDescriptionFlag.IncludesSecondHeader matches).
// 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. 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)
{
if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
weenieFlags2 = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000001u) != 0) // PluralName
pluralName = CreateObject.ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
// PublicWeenieDesc::UnPack @ 0x005AD52C uses MOVSX.
// ACE stores the creature/non-container sentinel -1 as
// 0xFF; retail retains -1 rather than turning it into 255.
wItemsCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity s8 -> int
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
// PublicWeenieDesc::UnPack @ 0x005AD53E likewise uses
// MOVSX before assigning the 32-bit public field.
wContainersCapacity = unchecked((sbyte)body[pos]);
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
ammoType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
wValue = (int)CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc UseRadius");
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");
targetType = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
{
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
combatUse = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00004000u) != 0) // Container u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Container");
wContainerId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
wWielderId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
{
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
wValidLocations = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
wCurrentWieldedLocation = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
wPriority = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
radarBlipColor = body[pos]; pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
radarBehavior = body[pos]; 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");
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
}
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
houseOwnerId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) ← CAPTURE (AP-129)
{
// Wire layout per Chorizite RestrictionDB.generated.cs (verified against
// protocol.xml:6270-6275): u32 Version, u32 Flags, u32 MonarchId, then a
// PHashTable<uint,uint> (NOT the u16-count/u16-size PackableHashTable shape
// this skip previously assumed — the byte total happened to match for any
// realistic guest-list size, but the count extraction was reading the wrong
// half of the packed dword). PHashTable's own header is ONE packed u32 whose
// low 24 bits are the entry count.
if (body.Length - pos < 12) throw new FormatException("trunc RestrictionDB header");
pos += 4; // Version — not consulted
uint flags = CreateObject.ReadU32(body, ref pos); // 0 = private, 1 = open
uint restrictionMonarchId = CreateObject.ReadU32(body, ref pos);
if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header");
uint packedSize = CreateObject.ReadU32(body, ref pos);
uint entryCount = packedSize & 0xFFFFFFu;
long entryBytes = (long)entryCount * 8; // each entry: u32 guid + u32 value
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
var guests = new Dictionary<uint, uint>((int)entryCount);
for (uint i = 0; i < entryCount; i++)
{
uint guestId = CreateObject.ReadU32(body, ref pos);
uint permission = CreateObject.ReadU32(body, ref pos);
guests[guestId] = permission;
}
restrictions = new HouseRestrictionRecord(
OpenToPublic: flags != 0,
AllegianceMonarchId: restrictionMonarchId,
Guests: guests);
}
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
wHookItemTypes = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129)
{
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
monarchId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc HookType");
wHookType = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconOverlayId = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.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 = CreateObject.ReadPackedDwordOfKnownType(body, ref pos, CreateObject.IconTypePrefix);
}
// PublicWeenieDesc::UnPack @ 0x005AD7AC..0x005AD7F8 and ACE
// SerializeCreateObject both place these fields after IconUnderlay.
if ((weenieFlags & 0x80000000u) != 0) // MaterialType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc MaterialType");
materialType = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
cooldownId = CreateObject.ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
{
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8;
}
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
{
if (body.Length - pos < 4) throw new FormatException("trunc PetOwner");
petOwnerId = CreateObject.ReadU32(body, ref pos);
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
return new PublicWeenieDescBody(
Name: name,
ItemType: itemType,
ObjectDescriptionFlags: objectDescriptionFlags,
IconId: iconId,
WeenieClassId: weenieClassId,
Value: wValue,
StackSize: wStackSize,
StackSizeMax: wMaxStackSize,
Burden: wBurden,
ItemsCapacity: wItemsCapacity,
ContainersCapacity: wContainersCapacity,
HookItemTypes: wHookItemTypes,
HookType: wHookType,
ContainerId: wContainerId,
WielderId: wWielderId,
ValidLocations: wValidLocations,
CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority,
Structure: wStructure,
MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
Useability: useability,
UseRadius: useRadius,
TargetType: targetType,
IconOverlayId: iconOverlayId,
IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects,
RadarBlipColor: radarBlipColor,
RadarBehavior: radarBehavior,
CombatUse: combatUse,
PluralName: pluralName,
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
CooldownId: cooldownId,
CooldownDuration: cooldownDuration,
MaterialType: materialType,
HouseOwnerId: houseOwnerId,
MonarchId: monarchId,
Restrictions: restrictions);
}
}