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:
parent
7bd4388b6b
commit
e45c95b06c
2 changed files with 473 additions and 0 deletions
224
src/AcDream.Core.Net/Messages/VendorApproach.cs
Normal file
224
src/AcDream.Core.Net/Messages/VendorApproach.cs
Normal 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 << 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
249
tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs
Normal file
249
tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue