fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
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

All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 12:50:28 +02:00
parent 92ea3977b6
commit c68ad1e646
11 changed files with 1314 additions and 99 deletions

View file

@ -136,6 +136,83 @@ public sealed class VendorSellAcceptabilityTests
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>
/// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): a trade note
/// above the vendor's max value is EXEMPT — the actual x86 is
/// <c>(~(itemTypeMask &gt;&gt; 16)) &amp; 4</c>, and PromissoryNote's bit
/// (0x00040000) lands exactly on that mask, zeroing the result.
/// </summary>
[Fact]
public void PromissoryNoteAboveMaxValueIsExemptFromTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: (uint)ItemType.PromissoryNote,
perUnitValue: 999_999,
merchandiseItemTypes: (uint)ItemType.PromissoryNote,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>An ordinary (non-note) item above max value is still rejected — the exemption is note-specific.</summary>
[Fact]
public void NonPromissoryNoteAboveMaxValueIsStillTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 999_999,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.TooValuable, rejection);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>
/// (<see cref="PublicWeenieFlags.Retained"/>, 0x01000000) folds into
/// the SAME <see cref="VendorSellRejection.WrongType"/> outcome as a
/// genuine type mismatch — retail's InqAcceptability ORs the two
/// conditions together (pc:005d1aa7).
/// </summary>
[Fact]
public void RetainedItemIsRejectedAsWrongTypeEvenWhenTheTypeMaskMatches()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Retained);
Assert.Equal(VendorSellRejection.WrongType, rejection);
}
/// <summary>An item with OTHER bitfield bits set (not Retained) is unaffected.</summary>
[Fact]
public void NonRetainedBitfieldDoesNotAffectAcceptability()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Stuck);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AnOrdinaryAcceptableItemReturnsNone()
{

View file

@ -26,8 +26,14 @@ public sealed class VendorStagingListTests
Assert.False(list.IsEmpty);
}
/// <summary>
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
/// already-staged guid ACCUMULATES onto the existing quantity — a prior
/// version of this port upserted/overwrote instead, which this test
/// used to assert (20, not 25).
/// </summary>
[Fact]
public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating()
public void AddingTheSameGuidTwiceAccumulatesRatherThanDuplicatingOrOverwriting()
{
var list = new VendorStagingList();
@ -35,7 +41,64 @@ public sealed class VendorStagingListTests
list.Add(ItemA, 20);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(20, entry.Quantity);
Assert.Equal(25, entry.Quantity);
}
[Fact]
public void AddReturnsAddedOnASuccessfulStage()
{
var list = new VendorStagingList();
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
}
/// <summary>
/// F2: retail's 5000-unit cap (<c>0x1388</c>) guards the ACCUMULATE
/// path only — exceeding it on a re-add rejects and leaves the entry
/// COMPLETELY UNCHANGED (<c>pc:202936-202951</c>).
/// </summary>
[Fact]
public void AddAccumulatingPastTheCapIsRejectedAndLeavesTheEntryUnchanged()
{
var list = new VendorStagingList();
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
VendorStagingAddOutcome outcome = list.Add(ItemA, 11);
Assert.Equal(VendorStagingAddOutcome.Capped, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity - 10, entry.Quantity);
}
[Fact]
public void AddAccumulatingExactlyToTheCapSucceeds()
{
var list = new VendorStagingList();
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
VendorStagingAddOutcome outcome = list.Add(ItemA, 10);
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity, entry.Quantity);
}
/// <summary>
/// F2: a BRAND-NEW entry has no cap in retail's own <c>AddToBuyList</c>
/// — the cap check lives only inside the "found an existing match"
/// branch (<c>label_4c3e20</c>'s insert has none, <c>pc:202895-202923</c>).
/// </summary>
[Fact]
public void AddOfABrandNewEntryHasNoCapEvenAboveTheThreshold()
{
var list = new VendorStagingList();
VendorStagingAddOutcome outcome = list.Add(ItemA, VendorStagingList.MaxStagedQuantity + 500);
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity + 500, entry.Quantity);
}
[Theory]
@ -46,8 +109,9 @@ public sealed class VendorStagingListTests
{
var list = new VendorStagingList();
list.Add(guid, quantity);
VendorStagingAddOutcome outcome = list.Add(guid, quantity);
Assert.Equal(VendorStagingAddOutcome.Ignored, outcome);
Assert.True(list.IsEmpty);
}