diff --git a/src/AcDream.Core.Net/Messages/VendorApproach.cs b/src/AcDream.Core.Net/Messages/VendorApproach.cs
new file mode 100644
index 00000000..a517bcf0
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/VendorApproach.cs
@@ -0,0 +1,224 @@
+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;
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs b/tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs
new file mode 100644
index 00000000..6b9332f3
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs
@@ -0,0 +1,249 @@
+using AcDream.Core.Items;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Golden-byte tests for Slice 5.1's ApproachVendor (GameEvent
+/// 0x0062) inbound parser ().
+/// Payload-only (post-envelope), matching
+/// GameEventsInventoryTests.cs's convention — the GameEvent
+/// 0xF7B0 envelope itself is a separate, already-tested layer
+/// (GameEventEnvelope.TryParse).
+///
+///
+/// PWD-tail field coverage (every optional bit, house restrictions, icon
+/// overlay/underlay, etc.) is already exhaustively tested against the SAME
+/// shared parser in CreateObjectTests.cs (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 SerializeGameDataOnly/SerializeCreateObject
+/// calls writer.Align() unconditionally at the end of EVERY object
+/// body, confirmed independently by Chorizite's
+/// PublicWeenieDesc.generated.cs:348-350/477-479 — see the doc
+/// comment on 's per-item loop).
+///
+///
+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();
+ }
+}