diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index b04d2707..5b5163d4 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -535,7 +535,9 @@ public static class CreateObject // _bitfield (acclient.h:6431-6463) was previously discarded as // "ObjectDescriptionFlags" at the WeenieHeader trailer. uint? physicsState = null; - uint? objectDescriptionFlags = null; + // ObjectDescriptionFlags (retail PWD._bitfield) is captured inside + // PublicWeenieDescParser.Parse below (Slice 5.0 extraction) — no + // longer a top-level accumulator here. // L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded // when their PhysicsDescriptionFlag bits are set. Default values // come from PhysicsBody constructors; these overrides drive the @@ -778,383 +780,20 @@ public static class CreateObject DefaultScriptIntensity: defaultScriptIntensity, Timestamps: timestamps); - // --- 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; - if (body.Length - pos >= 4) - { - weenieFlags = ReadU32(body, ref pos); - try - { - name = ReadString16L(body, ref pos); - weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded) - iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); - if (body.Length - pos >= 4) - itemType = 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 = ReadU32(body, ref pos); - } - 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 = ReadU32(body, ref pos); - } - - if ((weenieFlags & 0x00000001u) != 0) // PluralName - pluralName = 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)ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP - { - if (body.Length - pos < 4) throw new FormatException("trunc Useability"); - useability = 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 = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE - { - if (body.Length - pos < 4) throw new FormatException("trunc UiEffects"); - uiEffects = 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 = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00008000u) != 0) // Wielder u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Wielder"); - wWielderId = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations"); - wValidLocations = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation"); - wCurrentWieldedLocation = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00040000u) != 0) // Priority u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Priority"); - wPriority = 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 = 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 = ReadU32(body, ref pos); // 0 = private, 1 = open - uint restrictionMonarchId = ReadU32(body, ref pos); - if (body.Length - pos < 4) throw new FormatException("trunc RestrictionDB PHashTable header"); - uint packedSize = 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 = ReadU32(body, ref pos); - uint permission = 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 = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 ← CAPTURE (AP-129) - { - if (body.Length - pos < 4) throw new FormatException("trunc Monarch"); - monarchId = 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 = ReadPackedDwordOfKnownType(body, ref pos, 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 = ReadPackedDwordOfKnownType(body, ref pos, 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 = ReadU32(body, ref pos); - } - if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc CooldownId"); - cooldownId = 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 = ReadU32(body, ref pos); - } - } - catch { /* truncated weenie tail — keep whatever we got. */ } + // --- WeenieHeader / PublicWeenieDesc body --- + // Extracted (Slice 5.0, world-interaction-completion plan + // decision 2) into PublicWeenieDescParser.Parse + // (PublicWeenieDescBody.cs) so the Slice 5.1 vendor item-list + // parser (ApproachVendor, GameEvent 0x0062) can share this exact + // conditional field cascade instead of duplicating it. + // Extraction only — same fields, same order, same truncation + // handling as the original inline code (both inner try/catches + // there swallowed and returned a partial result; this call does + // the same). + var desc = PublicWeenieDescParser.Parse(body, ref pos); return new Parsed(guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, + textureChanges, subPalettes, basePaletteId, objScale, desc.Name, desc.ItemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, movementSeq, PositionSequence: positionSeq, @@ -1162,34 +801,34 @@ public static class CreateObject ParentLocation: parentLocation, PlacementId: placementId, PhysicsState: physicsState, - ObjectDescriptionFlags: objectDescriptionFlags, + ObjectDescriptionFlags: desc.ObjectDescriptionFlags, Friction: friction, Elasticity: elasticity, - IconId: iconId, - Useability: useability, UseRadius: useRadius, TargetType: targetType, - IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId, - UiEffects: uiEffects, - 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, - RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior, - CombatUse: combatUse, - PluralName: pluralName, - PetOwnerId: petOwnerId, - AmmoType: ammoType, - SpellId: spellId, - CooldownId: cooldownId, - CooldownDuration: cooldownDuration, + IconId: desc.IconId, + Useability: desc.Useability, UseRadius: desc.UseRadius, TargetType: desc.TargetType, + IconOverlayId: desc.IconOverlayId, IconUnderlayId: desc.IconUnderlayId, + UiEffects: desc.UiEffects, + WeenieClassId: desc.WeenieClassId, + Value: desc.Value, StackSize: desc.StackSize, StackSizeMax: desc.StackSizeMax, + Burden: desc.Burden, ItemsCapacity: desc.ItemsCapacity, ContainersCapacity: desc.ContainersCapacity, + HookItemTypes: desc.HookItemTypes, HookType: desc.HookType, + ContainerId: desc.ContainerId, WielderId: desc.WielderId, + ValidLocations: desc.ValidLocations, CurrentWieldedLocation: desc.CurrentWieldedLocation, + Priority: desc.Priority, Structure: desc.Structure, MaxStructure: desc.MaxStructure, + Workmanship: desc.Workmanship, + RadarBlipColor: desc.RadarBlipColor, RadarBehavior: desc.RadarBehavior, + CombatUse: desc.CombatUse, + PluralName: desc.PluralName, + PetOwnerId: desc.PetOwnerId, + AmmoType: desc.AmmoType, + SpellId: desc.SpellId, + CooldownId: desc.CooldownId, + CooldownDuration: desc.CooldownDuration, Physics: physics, - MaterialType: materialType, - HouseOwnerId: houseOwnerId, - MonarchId: monarchId, - Restrictions: restrictions); + MaterialType: desc.MaterialType, + HouseOwnerId: desc.HouseOwnerId, + MonarchId: desc.MonarchId, + Restrictions: desc.Restrictions); } catch { @@ -1271,7 +910,12 @@ public static class CreateObject return new ModelData(basePaletteId, subPalettes, textureChanges, animParts); } - private static uint ReadU32(ReadOnlySpan source, ref int pos) + // Widened from `private` to `internal` (Slice 5.0, extraction only) so + // PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs) — the shared + // desc-body parser used by both CreateObject and the upcoming vendor + // item-list parser — can reuse these primitive readers instead of + // duplicating them. No behavior change. + internal static uint ReadU32(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 4) throw new FormatException("truncated u32"); uint v = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); @@ -1279,7 +923,7 @@ public static class CreateObject return v; } - private static string ReadString16L(ReadOnlySpan source, ref int pos) + internal static string ReadString16L(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 2) throw new FormatException("truncated String16L length"); ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); @@ -1307,7 +951,7 @@ public static class CreateObject /// written as a little-endian u32. The reader is the inverse — sees the high- /// bit marker in the first u16, then reads the second u16. /// - private static uint ReadPackedDword(ReadOnlySpan source, ref int pos) + internal static uint ReadPackedDword(ReadOnlySpan source, ref int pos) { if (source.Length - pos < 2) throw new FormatException("truncated PackedDword"); ushort first = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); @@ -1331,13 +975,13 @@ public static class CreateObject /// recover the original dat id. The zero sentinel is preserved as-is /// (a 0 means "no value" and must not be turned into knownType). /// - private static uint ReadPackedDwordOfKnownType(ReadOnlySpan source, ref int pos, uint knownType) + internal static uint ReadPackedDwordOfKnownType(ReadOnlySpan source, ref int pos, uint knownType) { uint packed = ReadPackedDword(source, ref pos); return packed == 0 ? 0 : (packed | knownType); } - private static void AlignTo4(ref int pos) + internal static void AlignTo4(ref int pos) { int padding = (4 - (pos & 3)) & 3; pos += padding; diff --git a/src/AcDream.Core.Net/Messages/PublicWeenieDescBody.cs b/src/AcDream.Core.Net/Messages/PublicWeenieDescBody.cs new file mode 100644 index 00000000..0565d77e --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PublicWeenieDescBody.cs @@ -0,0 +1,510 @@ +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); + } +}