feat(net): Slice 5.1 — ApproachVendor (GameEvent 0x0062) inbound parser

VendorApproach.TryParse reads the vendor profile and the item list per
the byte-verified field table (research doc §A.2), each item through
the shared PublicWeenieDescParser from 5.0 — zero duplicated parsing.
One wire detail the research table did not spell out, found by
re-reading ACE's writer and confirmed independently in Chorizite's
generated readers: every object body is 4-byte-aligned at its END, so
back-to-back vendor items need an explicit AlignTo4 between entries
(CreateObject never needed it — nothing follows its body). Pinned by a
dedicated test forcing a real 2-byte misalignment via AmmoType.
Stack-size sign extension cross-checked against holtburger.

Six tests: field-order with distinct literals, empty list, and
truncation at each structural boundary — mid-item-tail truncation
deliberately inherits 5.0's established non-throwing partial-item
contract instead of asserting null everywhere.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 15:05:47 +02:00
parent 7bd4388b6b
commit e45c95b06c
2 changed files with 473 additions and 0 deletions

View file

@ -0,0 +1,224 @@
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>ApproachVendor</c> GameEvent (<c>0x0062</c>) — the sole wire
/// message that opens a vendor's shop. Carries the vendor's shop terms
/// (<see cref="VendorProfile"/>: 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 (<c>InteractRequests.UseOpcode</c>)
/// like any other useable NPC; see
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §A.1-A.2.
///
/// <para>
/// Every <c>ApproachVendor</c> 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 <c>VendorState</c>) should apply this as a full
/// snapshot, matching how <c>ExternalContainerState</c>/<c>ViewContents</c>
/// already model "authoritative full replace."
/// </para>
///
/// <para>
/// Field-by-field wire layout cross-verified across three independent
/// sources with zero disagreement: ACE's writer
/// (<c>references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventApproachVendor.cs</c>),
/// retail's decompiled <c>VendorProfile::UnPack</c>/<c>ItemProfile::UnPack</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:484940-484963</c>
/// / <c>:484668-484742</c>, symbols <c>0x005D1D20</c> / <c>0x005D1910</c>),
/// and Chorizite's generated reader/writer
/// (<c>references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/VendorProfile.generated.cs</c>,
/// <c>ItemProfile.generated.cs</c>, <c>PublicWeenieDesc.generated.cs</c>).
/// </para>
/// </summary>
public static class VendorApproach
{
/// <summary>
/// Vendor shop terms — the fixed-size prefix of the <c>ApproachVendor</c>
/// payload (everything before the item list). Field order verified
/// against ACE <c>GameEventApproachVendor.cs:14-46</c>, retail
/// <c>VendorProfile::UnPack</c> (<c>pc:484940-484963</c>), and Chorizite
/// <c>VendorProfile.generated.cs:65-75</c> — all three agree byte-for-byte.
/// </summary>
/// <param name="MerchandiseItemTypes">
/// Bitmask of <see cref="AcDream.Core.Items.ItemType"/> categories the
/// vendor will buy from the player (retail <c>item_types</c>).
/// </param>
/// <param name="MerchandiseMinValue">Lowest item value the vendor buys (retail <c>min_value</c>).</param>
/// <param name="MerchandiseMaxValue">Highest item value the vendor buys (retail <c>max_value</c>).</param>
/// <param name="DealMagicalItems">Whether the vendor buys magical items (retail <c>magic</c>, wire u32 0/1).</param>
/// <param name="BuyPrice">
/// 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 <c>buy_price</c>; see
/// <see cref="AcDream.Core.Items.VendorPricing"/>'s doc comment for the
/// full inversion warning.
/// </param>
/// <param name="SellPrice">
/// The vendor's SELL rate — applied when the vendor sells an item TO the
/// player (i.e. what the player pays to buy). Retail <c>sell_price</c>.
/// </param>
/// <param name="AlternateCurrencyWcid">
/// Weenie class id of the alternate currency this vendor accepts instead
/// of pyreals, or 0 for an ordinary pyreal vendor (retail
/// <c>trade_id.id</c>).
/// </param>
/// <param name="AlternateCurrencyAmount">
/// 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 <see cref="AlternateCurrencyPluralName"/>).
/// </param>
/// <param name="AlternateCurrencyPluralName">
/// The alternate currency's plural display name, or the empty string for
/// a pyreal vendor. Always present on the wire (see
/// <see cref="AlternateCurrencyAmount"/>).
/// </param>
public readonly record struct VendorProfile(
uint MerchandiseItemTypes,
uint MerchandiseMinValue,
uint MerchandiseMaxValue,
bool DealMagicalItems,
float BuyPrice,
float SellPrice,
uint AlternateCurrencyWcid,
uint AlternateCurrencyAmount,
string AlternateCurrencyPluralName);
/// <summary>
/// One item for sale. Per-item wire shape (retail
/// <c>ItemProfile::UnPack</c>, <c>pc:484668-484742</c>,
/// <c>0x005D1910</c>; ACE <c>obj.SerializeGameDataOnly(Writer)</c> →
/// <c>SerializeCreateObject(writer, gamedataonly: true, ...)</c>):
/// <list type="number">
/// <item>packed u32: low 24 bits = <see cref="StackSize"/> (sign-
/// extended; -1 = unlimited supply), high 8 bits = pwdType (always
/// -1/<c>PublicWeenieDesc</c> in practice — ACE's writer hardcodes
/// <c>-1 &lt;&lt; 24</c> unconditionally, so the legacy
/// <c>OldPublicWeenieDesc</c> branch Chorizite's reader still
/// switches on is never exercised by a real server and is not
/// modeled here).</item>
/// <item>u32 <see cref="ItemGuid"/> — read BEFORE the desc body, a
/// distinct field from anything <see cref="PublicWeenieDescParser"/>
/// reads.</item>
/// <item>The SAME <c>PublicWeenieDesc</c> body <c>CreateObject</c>
/// uses, minus model/physics data (<see cref="Desc"/>).</item>
/// </list>
/// </summary>
public readonly record struct ItemProfile(
int StackSize,
uint ItemGuid,
PublicWeenieDescBody Desc);
public readonly record struct Parsed(
uint VendorGuid,
VendorProfile Profile,
IReadOnlyList<ItemProfile> Items);
/// <summary>
/// Defensive cap on the parsed item count, mirroring
/// <see cref="CreateObject.TryParse"/>'s <c>Children</c> 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.
/// </summary>
private const int MaxItems = 8192;
/// <summary>
/// Parse the <c>ApproachVendor</c> GameEvent payload (post-envelope —
/// <paramref name="payload"/> starts at the vendor's own guid, matching
/// every other <c>GameEvents.Parse*</c> convention). Returns
/// <c>null</c> on a truncated/malformed profile or item-count-vs-actual-
/// bytes mismatch. Per-item <c>PublicWeenieDesc</c> truncation degrades
/// gracefully instead of failing the whole parse — see
/// <see cref="PublicWeenieDescParser.Parse"/>'s doc comment; that
/// behavior is inherited unchanged here since this parser is the second
/// caller of the shared body walker.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> 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<ItemProfile>)System.Array.Empty<ItemProfile>()
: 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;
}
}
}