acdream/tests/AcDream.Core.Net.Tests/Messages/VendorApproachTests.cs
Erik e45c95b06c 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>
2026-08-07 15:05:47 +02:00

249 lines
11 KiB
C#

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