using System; using System.Buffers.Binary; using System.Collections.Generic; namespace AcDream.Core.Net.Messages; /// /// Outbound vendor GameActions. Slice 6.3: Buy (0x005F) only — Sell /// (0x0060) is research-only scope (Slice 6 research doc §A.3), not /// implemented here. /// /// /// Wire layout, confirmed FOUR ways with zero disagreement (research doc /// §A.1: ACE's reader, Chorizite's generated reader/writer, holtburger's /// independent client, and the retail decompiled sender — /// CM_Vendor::Event_Buy, pc:689288, 0x006AA0F0): /// /// u32 0xF7B1 // GameAction envelope /// u32 gameActionSequence /// u32 0x005F // Buy opcode /// u32 vendorGuid /// u32 itemCount /// per item: /// i32 amount // quantity to buy (plain positive int32, /// // NOT ItemProfile's packed sign-extended /// // supply-count field) /// u32 objectGuid // the SHOP ITEM's guid /// u32 alternateCurrencyId // TRAILING — 0 for a pyreal vendor /// /// /// /// /// The trailing alternateCurrencyId field — a deliberate, /// evidence-backed divergence from ACE. Retail's client /// (CM_Vendor::Event_Buy) writes this field on EVERY Buy, after the /// packed item list, every time (pc:689335-689336). ACE's current /// server-side reader has the matching line PRESENT but COMMENTED OUT /// (GameActionBuyItems.cs:32, /// //var altCurrencyWcid = message.Payload.ReadUInt32();) — it simply /// never reads the trailing bytes. holtburger, a real client written and /// tested against ACE's actual accepted wire shape, omits the field /// entirely and round-trips fine against ACE. Both are correct for what /// they target: ACE demonstrably does not NEED this field today. We port /// the field anyway because retail — the top oracle per this project's /// CLAUDE.md — sends it unconditionally, it costs one u32, and it /// costs ACE nothing to ignore (forward-compatible with any future ACE /// build that un-comments its read). Do not "fix" this by dropping the /// field without re-reading /// docs/research/2026-08-08-slice6-vendor-transactions-research.md /// §A.1 first — that document's "Resolution of the ACE/holtburger vs. /// retail disagreement" section is the full reasoning trail. /// /// public static class VendorRequests { public const uint GameActionEnvelope = 0xF7B1u; public const uint BuyOpcode = 0x005Fu; /// /// Build a Buy GameAction for — retail's /// CM_Vendor::Event_Buy(vendorGuid, &list, currencyId). Slice /// 6.3's Buy button always passes a ONE-entry list (retail /// BuySingleItem, pc:201661, has no staging-list /// dependency — see the Slice 6 research doc §B.1); the list shape is /// kept general because that is literally the wire message's own shape /// (a future "Buy All" staged-purchase path would reuse this builder /// unchanged, not because Slice 6.3 needs it today). /// public static byte[] BuildBuy( uint gameActionSequence, uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint alternateCurrencyId) { ArgumentNullException.ThrowIfNull(items); int itemCount = items.Count; byte[] body = new byte[24 + (itemCount * 8)]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), BuyOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), vendorGuid); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)itemCount); int offset = 20; for (int i = 0; i < itemCount; i++) { (int amount, uint itemGuid) = items[i]; BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset), amount); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 4), itemGuid); offset += 8; } BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset), alternateCurrencyId); return body; } /// Convenience overload for the single-item Buy Slice 6.3 sends. public static byte[] BuildBuy( uint gameActionSequence, uint vendorGuid, int amount, uint itemGuid, uint alternateCurrencyId) => BuildBuy( gameActionSequence, vendorGuid, new[] { (amount, itemGuid) }, alternateCurrencyId); }