Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The adversarial review's three blocking findings, each fixed at root: 1. A vendor session now CLOSES when its entity retires (despawn, death, ObjectDelete) and at teleport BEGIN (HasPendingTeleportStart || IsTeleportActive at the existing per-frame seam — both hosts funnel through RuntimeWorldTransitState.TryQueueTeleportStart, which flips the pending flag strictly before activation). The previous permissive early-return stranded the session forever: panel pinned to a stale guid, ActiveVendorId swallowing Use for the rest of the session. 2. VendorShopItem carries the desc's stack size, and VendorPricing.PerUnitValue ports retail's stack-total division (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer division) — a stack of 50 arrows now prices per arrow, not at 50x. 3. VendorState.Close() guards its observer fanout with the dispatcher's catch-and-log semantics — a throwing panel listener can no longer propagate into the unprotected per-frame path. Register honesty rides along: the 0.6 m UseRadius fallback was acdream's invention (ACE's CheckClose has no fallback; retail passes the raw authored radius) — removed, the watcher now uses the raw radius and AP-160's citations are corrected and extended with the accepted-position-snapshot cadence; AD-72 files VendorPricing's double-vs-x87-extended narrowing (AD-33's class, bounded by the ±0.1 margin). Nine tests added. Clean-room complete solution: 11,311 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
254 lines
12 KiB
C#
254 lines
12 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. This does NOT reach a guid read that fails --
|
|
// TryParse's own minimum-size guard (`(long)itemCount * 12 >
|
|
// payload.Length - pos`, the smallest possible per-item size:
|
|
// packed(4) + guid(4) + weenieFlags(4)) rejects the whole parse
|
|
// right after itemCount is read (remaining=4 bytes here, the packed
|
|
// dword only; 1 * 12 = 12 > 4), before the per-item loop that would
|
|
// read the packed dword/guid ever runs.
|
|
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();
|
|
}
|
|
}
|