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

View file

@ -0,0 +1,249 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte tests for Slice 5.1's <c>ApproachVendor</c> (GameEvent
/// <c>0x0062</c>) inbound parser (<see cref="VendorApproach.TryParse"/>).
/// Payload-only (post-envelope), matching
/// <c>GameEventsInventoryTests.cs</c>'s convention — the GameEvent
/// <c>0xF7B0</c> envelope itself is a separate, already-tested layer
/// (<c>GameEventEnvelope.TryParse</c>).
///
/// <para>
/// PWD-tail field coverage (every optional bit, house restrictions, icon
/// overlay/underlay, etc.) is already exhaustively tested against the SAME
/// shared parser in <c>CreateObjectTests.cs</c> (Slice 5.0's extraction
/// target). These tests focus on what is genuinely NEW here: the
/// vendor-specific framing — profile field order, item count, per-item
/// packed stack-size sign extension, and per-item trailing 4-byte
/// alignment (ACE's <c>SerializeGameDataOnly</c>/<c>SerializeCreateObject</c>
/// calls <c>writer.Align()</c> unconditionally at the end of EVERY object
/// body, confirmed independently by Chorizite's
/// <c>PublicWeenieDesc.generated.cs:348-350/477-479</c> — see the doc
/// comment on <see cref="VendorApproach.TryParse"/>'s per-item loop).
/// </para>
/// </summary>
public sealed class VendorApproachTests
{
[Fact]
public void TryParse_RepresentativeMultiItemVendor_FieldOrderAndItemsCorrect()
{
var w = new AceWireWriter();
w.Write(0x40000123u) // vendor guid
.Write(0x00000042u) // MerchandiseItemTypes (arbitrary bitmask)
.Write(10u) // MerchandiseMinValue
.Write(99999u) // MerchandiseMaxValue
.Write(1u) // DealMagicalItems = true
.Write(0.75f) // BuyPrice rate
.Write(1.25f) // SellPrice rate
.Write(0x34000001u) // AlternateCurrencyWcid
.Write(57u) // AlternateCurrencyAmount
.WriteString16L("Trade Notes") // AlternateCurrencyPluralName
.Write(2u); // item count
// Item 0: AmmoType (weenieFlags 0x100, a 2-byte field) leaves the
// cursor misaligned by 2 bytes at the end of the PWD body — this
// exercises the per-item trailing align that resyncs item 1's read.
WritePackedItemHeader(w, stackSize: 3, itemGuid: 0x50001001u);
WriteMinimalPwdBody(w, weenieFlags: 0x00000100u, name: "Dusty Tome",
weenieClassId: 7u, iconId: 8u, itemType: (uint)ItemType.Writable,
ammoType: 42);
// Item 1: unlimited stack (-1), plain body (no optional tail —
// already 4-aligned on its own). If item 0's trailing align were
// missing or wrong, this item's packed dword / guid / name would
// all read as garbage or the parse would throw.
WritePackedItemHeader(w, stackSize: -1, itemGuid: 0x50001002u);
WriteMinimalPwdBody(w, weenieFlags: 0u, name: "Iron Key",
weenieClassId: 55u, iconId: 66u, itemType: (uint)ItemType.Key);
byte[] payload = w.ToArray();
var parsed = VendorApproach.TryParse(payload);
Assert.NotNull(parsed);
var p = parsed!.Value;
// Field-order verification: every profile field carries a distinct
// literal value, so a swapped/misordered read would fail here.
Assert.Equal(0x40000123u, p.VendorGuid);
Assert.Equal(0x00000042u, p.Profile.MerchandiseItemTypes);
Assert.Equal(10u, p.Profile.MerchandiseMinValue);
Assert.Equal(99999u, p.Profile.MerchandiseMaxValue);
Assert.True(p.Profile.DealMagicalItems);
Assert.Equal(0.75f, p.Profile.BuyPrice);
Assert.Equal(1.25f, p.Profile.SellPrice);
Assert.Equal(0x34000001u, p.Profile.AlternateCurrencyWcid);
Assert.Equal(57u, p.Profile.AlternateCurrencyAmount);
Assert.Equal("Trade Notes", p.Profile.AlternateCurrencyPluralName);
Assert.Equal(2, p.Items.Count);
Assert.Equal(3, p.Items[0].StackSize);
Assert.Equal(0x50001001u, p.Items[0].ItemGuid);
Assert.Equal("Dusty Tome", p.Items[0].Desc.Name);
Assert.Equal((ushort)42, p.Items[0].Desc.AmmoType);
Assert.Equal(-1, p.Items[1].StackSize);
Assert.Equal(0x50001002u, p.Items[1].ItemGuid);
Assert.Equal("Iron Key", p.Items[1].Desc.Name);
Assert.Equal((uint)ItemType.Key, p.Items[1].Desc.ItemType);
}
[Fact]
public void TryParse_EmptyItemList_ReturnsEmptyItemsWithValidProfile()
{
var w = new AceWireWriter();
w.Write(0x40000200u) // vendor guid
.Write(0u) // MerchandiseItemTypes
.Write(0u) // MerchandiseMinValue
.Write(0xFFFFFFFFu) // MerchandiseMaxValue (retail's "no cap" sentinel)
.Write(0u) // DealMagicalItems = false
.Write(1.0f) // BuyPrice
.Write(1.0f) // SellPrice
.Write(0u) // AlternateCurrencyWcid (pyreal vendor)
.Write(0u) // AlternateCurrencyAmount
.WriteString16L("") // AlternateCurrencyPluralName (empty for pyreal vendor)
.Write(0u); // item count = 0
var parsed = VendorApproach.TryParse(w.ToArray());
Assert.NotNull(parsed);
Assert.Equal(0x40000200u, parsed!.Value.VendorGuid);
Assert.False(parsed.Value.Profile.DealMagicalItems);
Assert.Equal(0xFFFFFFFFu, parsed.Value.Profile.MerchandiseMaxValue);
Assert.Empty(parsed.Value.Items);
}
[Fact]
public void TryParse_TruncatedProfileMidField_ReturnsNull()
{
// CurrencyName's u16 length prefix says 5 bytes, but the buffer
// ends immediately after it. Unlike a truncated per-item PWD tail,
// the fixed profile prefix has no swallow-and-degrade behavior —
// ReadString16L throws and the whole message is rejected.
var w = new AceWireWriter();
w.Write(0x40000300u)
.Write(0u).Write(0u).Write(0u).Write(0u) // categories/min/max/dealsMagic
.Write(1.0f).Write(1.0f) // buy/sell rate
.Write(0u).Write(0u) // currency wcid/amount
.Write((ushort)5); // CurrencyName length prefix, no string bytes follow
Assert.Null(VendorApproach.TryParse(w.ToArray()));
}
[Fact]
public void TryParse_ItemCountExceedsActualBytes_ReturnsNull()
{
// itemCount says 2 but only one item's bytes are present. The
// second item's packed stack-size dword read runs out of buffer —
// this is OUR OWN per-item framing read (not part of the shared
// PublicWeenieDescParser's internal swallow), so it must fail the
// whole parse rather than degrade.
var w = new AceWireWriter();
WriteMinimalProfilePrefix(w, vendorGuid: 0x40000400u);
w.Write(2u); // item count = 2, but only one item follows
WritePackedItemHeader(w, stackSize: 1, itemGuid: 0x50002001u);
WriteMinimalPwdBody(w, weenieFlags: 0u, name: "Solo Item",
weenieClassId: 1u, iconId: 1u, itemType: (uint)ItemType.Misc);
Assert.Null(VendorApproach.TryParse(w.ToArray()));
}
[Fact]
public void TryParse_TruncatedMidItemPrefix_ReturnsNull()
{
// The item's packed stack-size dword is present but its guid is
// cut off entirely — truncation inside the per-item PREFIX (before
// PublicWeenieDescParser is even reached) must fail the whole parse.
var w = new AceWireWriter();
WriteMinimalProfilePrefix(w, vendorGuid: 0x40000500u);
w.Write(1u); // item count = 1
w.Write(0xFF000001u); // packed dword (stackSize=1) written; guid is NOT written
Assert.Null(VendorApproach.TryParse(w.ToArray()));
}
[Fact]
public void TryParse_TruncatedMidItemPwdTail_DegradesGracefully()
{
// Truncating INSIDE an item's PublicWeenieDesc tail hits the
// shared PublicWeenieDescParser.Parse's own internal try/catch,
// which never throws — it returns a partial record with whatever
// fields parsed before the cut (see that type's doc comment).
// VendorApproach.TryParse inherits that contract unchanged (Slice
// 5.0's extraction is behavior-preserving), so THIS truncation must
// NOT null out the whole vendor snapshot — only the last item's
// later fields go missing.
var w = new AceWireWriter();
WriteMinimalProfilePrefix(w, vendorGuid: 0x40000600u);
w.Write(1u); // item count = 1
WritePackedItemHeader(w, stackSize: 4, itemGuid: 0x50002100u);
// weenieFlags 0x8 (Value, u32) | 0x10 (Useability, u32). Capture the
// cursor right after Value is written, before Useability.
w.Write(0x00000018u) // weenieFlags: Value | Useability
.WriteString16L("Cut Short")
.WritePackedDword(9u) // weenieClassId
.WritePackedDword(10u) // iconId
.Write((uint)ItemType.Misc) // itemType
.Write(0u) // objectDescriptionFlags
.Align();
w.Write(777u); // Value — this must survive the cut
int truncateAt = w.Length;
w.Write(1u); // Useability — this must NOT survive the cut
byte[] payload = w.ToArray();
byte[] truncated = payload[..truncateAt];
var parsed = VendorApproach.TryParse(truncated);
Assert.NotNull(parsed);
var item = Assert.Single(parsed!.Value.Items);
Assert.Equal("Cut Short", item.Desc.Name);
Assert.Equal(777, item.Desc.Value);
Assert.Null(item.Desc.Useability);
}
// ---- shared fixture helpers -------------------------------------------
private static void WriteMinimalProfilePrefix(AceWireWriter w, uint vendorGuid)
{
w.Write(vendorGuid)
.Write(0u).Write(0u).Write(0xFFFFFFFFu)
.Write(0u)
.Write(1.0f).Write(1.0f)
.Write(0u).Write(0u)
.WriteString16L("");
}
private static void WritePackedItemHeader(AceWireWriter w, int stackSize, uint itemGuid)
{
// ACE's writer: `stackSize & 0xFFFFFF | -1 << 24` — low 24 bits are
// the (possibly negative, sign-extended) stack size; the high byte
// is always 0xFF (pwdType -1) in practice.
uint packed = ((uint)stackSize & 0xFFFFFFu) | 0xFF000000u;
w.Write(packed).Write(itemGuid);
}
private static void WriteMinimalPwdBody(
AceWireWriter w, uint weenieFlags, string name, uint weenieClassId,
uint iconId, uint itemType, ushort? ammoType = null)
{
w.Write(weenieFlags)
.WriteString16L(name)
.WritePackedDword(weenieClassId)
.WritePackedDword(iconId)
.Write(itemType)
.Write(0u) // objectDescriptionFlags
.Align();
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
w.Write(ammoType ?? (ushort)0);
w.Align();
}
}