using System.Buffers.Binary; using System.Collections.Generic; namespace AcDream.Core.Net.Messages; /// /// Inbound ApproachVendor GameEvent (0x0062) — the sole wire /// message that opens a vendor's shop. Carries the vendor's shop terms /// (: buy/sell rates, currency, categories) plus /// the full item-for-sale list. There is no separate "open vendor" opcode — /// this rides the ordinary Use action (InteractRequests.UseOpcode) /// like any other useable NPC; see /// docs/research/2026-08-08-slice5-vendor-browse-research.md §A.1-A.2. /// /// /// Every ApproachVendor is a COMPLETE REPLACE, not a delta — there is /// no patch opcode for vendor contents in ACE or the retail decomp (§A.3). /// Consumers (Slice 5.2's VendorState) should apply this as a full /// snapshot, matching how ExternalContainerState/ViewContents /// already model "authoritative full replace." /// /// /// /// Field-by-field wire layout cross-verified across three independent /// sources with zero disagreement: ACE's writer /// (references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventApproachVendor.cs), /// retail's decompiled VendorProfile::UnPack/ItemProfile::UnPack /// (docs/research/named-retail/acclient_2013_pseudo_c.txt:484940-484963 /// / :484668-484742, symbols 0x005D1D20 / 0x005D1910), /// and Chorizite's generated reader/writer /// (references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/VendorProfile.generated.cs, /// ItemProfile.generated.cs, PublicWeenieDesc.generated.cs). /// /// public static class VendorApproach { /// /// Vendor shop terms — the fixed-size prefix of the ApproachVendor /// payload (everything before the item list). Field order verified /// against ACE GameEventApproachVendor.cs:14-46, retail /// VendorProfile::UnPack (pc:484940-484963), and Chorizite /// VendorProfile.generated.cs:65-75 — all three agree byte-for-byte. /// /// /// Bitmask of categories the /// vendor will buy from the player (retail item_types). /// /// Lowest item value the vendor buys (retail min_value). /// Highest item value the vendor buys (retail max_value). /// Whether the vendor buys magical items (retail magic, wire u32 0/1). /// /// The vendor's BUY rate — applied when the vendor buys an item FROM the /// player (i.e. what the player receives when selling). Naming reads /// backwards from English-first intuition: this is NOT "what the player /// pays to buy." Retail buy_price; see /// 's doc comment for the /// full inversion warning. /// /// /// The vendor's SELL rate — applied when the vendor sells an item TO the /// player (i.e. what the player pays to buy). Retail sell_price. /// /// /// Weenie class id of the alternate currency this vendor accepts instead /// of pyreals, or 0 for an ordinary pyreal vendor (retail /// trade_id.id). /// /// /// The player's current holding of that currency (0 for a pyreal /// vendor). Always present on the wire — the value is 0 rather than the /// field being absent when there is no alternate currency (ACE /// unconditionally writes both this and ). /// /// /// The alternate currency's plural display name, or the empty string for /// a pyreal vendor. Always present on the wire (see /// ). /// public readonly record struct VendorProfile( uint MerchandiseItemTypes, uint MerchandiseMinValue, uint MerchandiseMaxValue, bool DealMagicalItems, float BuyPrice, float SellPrice, uint AlternateCurrencyWcid, uint AlternateCurrencyAmount, string AlternateCurrencyPluralName); /// /// One item for sale. Per-item wire shape (retail /// ItemProfile::UnPack, pc:484668-484742, /// 0x005D1910; ACE obj.SerializeGameDataOnly(Writer) → /// SerializeCreateObject(writer, gamedataonly: true, ...)): /// /// packed u32: low 24 bits = (sign- /// extended; -1 = unlimited supply), high 8 bits = pwdType (always /// -1/PublicWeenieDesc in practice — ACE's writer hardcodes /// -1 << 24 unconditionally, so the legacy /// OldPublicWeenieDesc branch Chorizite's reader still /// switches on is never exercised by a real server and is not /// modeled here). /// u32 — read BEFORE the desc body, a /// distinct field from anything /// reads. /// The SAME PublicWeenieDesc body CreateObject /// uses, minus model/physics data (). /// /// public readonly record struct ItemProfile( int StackSize, uint ItemGuid, PublicWeenieDescBody Desc); public readonly record struct Parsed( uint VendorGuid, VendorProfile Profile, IReadOnlyList Items); /// /// Defensive cap on the parsed item count, mirroring /// 's Children array cap. A /// real vendor shop never approaches this size; this only guards against /// allocating an absurd array from a corrupted item-count field before /// the byte-availability check below has a chance to reject it. /// private const int MaxItems = 8192; /// /// Parse the ApproachVendor GameEvent payload (post-envelope — /// starts at the vendor's own guid, matching /// every other GameEvents.Parse* convention). Returns /// null on a truncated/malformed profile or item-count-vs-actual- /// bytes mismatch. Per-item PublicWeenieDesc truncation degrades /// gracefully instead of failing the whole parse — see /// 's doc comment; that /// behavior is inherited unchanged here since this parser is the second /// caller of the shared body walker. /// public static Parsed? TryParse(ReadOnlySpan payload) { try { int pos = 0; uint vendorGuid = CreateObject.ReadU32(payload, ref pos); uint categories = CreateObject.ReadU32(payload, ref pos); uint minValue = CreateObject.ReadU32(payload, ref pos); uint maxValue = CreateObject.ReadU32(payload, ref pos); bool dealsMagic = CreateObject.ReadU32(payload, ref pos) != 0; if (payload.Length - pos < 8) return null; float buyPrice = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4; float sellPrice = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4; uint currencyWcid = CreateObject.ReadU32(payload, ref pos); // Always present regardless of AlternateCurrencyWcid — ACE // unconditionally writes an amount + a (possibly empty) // String16L (GameEventApproachVendor.cs:30-46), it never omits // the fields for a pyreal vendor. uint currencyAmount = CreateObject.ReadU32(payload, ref pos); string currencyName = CreateObject.ReadString16L(payload, ref pos); var profile = new VendorProfile( categories, minValue, maxValue, dealsMagic, buyPrice, sellPrice, currencyWcid, currencyAmount, currencyName); uint itemCount = CreateObject.ReadU32(payload, ref pos); if (itemCount > MaxItems) return null; // Minimum per-item size on the wire is packed(4) + guid(4) + // weenieFlags(4) = 12 bytes; reject an item count the remaining // payload physically cannot hold before allocating the array. if ((long)itemCount * 12 > payload.Length - pos) return null; var items = itemCount == 0 ? (IReadOnlyList)System.Array.Empty() : new ItemProfile[itemCount]; for (int i = 0; i < itemCount; i++) { uint packed = CreateObject.ReadU32(payload, ref pos); // Sign-extend the low 24 bits: 0xFFFFFF -> -1 (unlimited // supply). Matches holtburger's independent cross-check // (`(packed << 8) as i32 >> 8`, // crates/holtburger-world/src/hydration.rs:33-40): shifting // the low 24 bits into the top of a 32-bit word then back // down with an ARITHMETIC (sign-extending) shift recovers a // signed 24-bit value regardless of the discarded high byte // (pwdType, always 0xFF/-1 in practice). int stackSize = unchecked((int)(packed << 8)) >> 8; uint itemGuid = CreateObject.ReadU32(payload, ref pos); // Same PublicWeenieDesc body CreateObject uses. This call // never throws — a truncated tail degrades to a partial // record with pos left at the truncation point (see the // shared parser's doc comment). var desc = PublicWeenieDescParser.Parse(payload, ref pos); // ACE's SerializeCreateObject calls writer.Align() at the // END of every object body UNCONDITIONALLY — including the // gamedataonly=true path SerializeGameDataOnly uses for shop // items (WorldObject_Networking.cs:220), and Chorizite's // PublicWeenieDesc.Read/Write independently confirms the // same trailing align (PublicWeenieDesc.generated.cs:348-350 // / 477-479). The shared PublicWeenieDescParser.Parse does // NOT perform this align itself (CreateObject.TryParse never // needed it — a CreateObject message has nothing after the // desc body to misalign). Here there IS a next item (or the // message end), so the align is mandatory before advancing. CreateObject.AlignTo4(ref pos); ((ItemProfile[])items)[i] = new ItemProfile(stackSize, itemGuid, desc); } return new Parsed(vendorGuid, profile, items); } catch { return null; } } }