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;
}
}
}