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>
249 lines
11 KiB
C#
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();
|
|
}
|
|
}
|