using System.Buffers.Binary; using System.Collections.Generic; using AcDream.Core.Items; namespace AcDream.Core.Net.Messages; /// /// The result of parsing a retail PublicWeenieDesc body: the fixed /// weenieFlags/name/wcid/icon/itemType/objDescFlags prefix, plus every /// conditional optional-tail field, in exact wire order. /// /// /// This is the SAME parse retail's PublicWeenieDesc::UnPack performs /// (and ACE's WorldObject_Networking.SerializeCreateObject writes) at /// two distinct wire sites: the CreateObject GameMessage (0xF745, /// where this body follows Guid + ModelData + PhysicsData), and the vendor /// ApproachVendor item list (GameEvent 0x0062, where — per ACE's /// gamedataonly 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 /// docs/research/2026-08-08-slice5-vendor-browse-research.md §A.2. /// /// /// /// Extracted from (Slice 5.0, /// docs/plans/2026-07-23-world-interaction-completion.md 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. /// /// 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); /// /// Parses the shared wire shape. See the /// type doc above for the two call sites and the extraction rationale. /// public static class PublicWeenieDescParser { /// /// Parse a PublicWeenieDesc body starting at . /// 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. /// public static PublicWeenieDescBody Parse(ReadOnlySpan 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 (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((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); } }