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(); + } +}