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

@ -26,6 +26,13 @@ public enum PublicWeenieFlags : uint
Healer = 0x00010000,
Lockpick = 0x00020000,
RequiresPackSlot = 0x00800000,
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
/// <c>VendorProfile::InqAcceptability</c> tests (<c>pc:005d1aa7</c>,
/// byte 3 bit 0 of <c>PublicWeenieDesc::_bitfield</c>). See
/// <see cref="VendorSellAcceptability"/>.
/// </summary>
Retained = 0x01000000,
VolatileRare = 0x10000000,
WieldOnUse = 0x20000000,
WieldLeft = 0x40000000,

View file

@ -18,8 +18,28 @@ public enum VendorSellRejection
/// <summary>
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
/// the generic case in practice, since a genuine bitmask is almost never
/// literally 1-4 (Slice 6b/6c research doc's open question #3).
/// mapped here to the generic "You cannot sell that here" message the
/// same way retail's <c>DragItemAcceptable</c> switch falls through for
/// any return value outside its four named cases (1-4).
/// <para>
/// F12 (Slice 6b/6c review) — SOFTENED CLAIM: an earlier version of this
/// doc comment asserted a genuine bitmask is "almost never literally
/// 1-4." That is not true in general: <see cref="ItemType"/>
/// <c>MeleeWeapon</c>/<c>Armor</c>/<c>Clothing</c> are the single bits
/// <c>1</c>/<c>2</c>/<c>4</c>, and a real specialist vendor's own
/// <c>MerchandiseItemTypes</c> could legitimately be exactly one of
/// them (a weapon-only, armor-only, or clothing-only shop is an
/// ordinary AC vendor archetype). For such a vendor, retail's raw
/// <c>item_types</c> return WOULD collide with <c>DragItemAcceptable</c>'s
/// own named cases 1/2/4 ("cannot be sold here" / "has no value" /
/// "too valuable") — a genuine type mismatch would show the WRONG
/// retail message, not the generic one. This class does not reproduce
/// that collision (it always returns the semantic
/// <see cref="WrongType"/> case, never a raw integer another case could
/// alias), so acdream's own message is unaffected either way; this note
/// only corrects the doc's claim about how often retail's OWN collision
/// is reachable, in case a byte-exact reproduction is ever wanted.
/// </para>
/// </summary>
WrongType,
@ -40,6 +60,19 @@ public enum VendorSellRejection
/// <summary>
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
/// <c>max_value != -1 &amp;&amp; value &gt; max_value</c>.
/// <para>
/// F3 (Slice 6b/6c review, byte-verified at <c>0x005d1add</c>): the
/// actual x86 at that return site is <c>mov eax,edi; shr eax,0x10;
/// not eax; and eax,4; ret</c> — i.e. <c>(~(itemTypeMask &gt;&gt; 16)) &amp; 4</c>,
/// a BITWISE complement (the decompiled pseudo-C's <c>!</c> is
/// misleading — it is not a logical NOT). <c>ItemType.PromissoryNote</c>
/// (<c>0x00040000</c>, bit 18) sits exactly at bit 2 of
/// <c>itemTypeMask &gt;&gt; 16</c>, so a trade note above the vendor's
/// max value returns 0 (fully <see cref="None"/>, exempt) instead of 4
/// (<see cref="TooValuable"/>) — and the <c>ret</c> at that exact
/// address means the min-value check below is skipped entirely for a
/// trade note, not merely the max-value rejection.
/// </para>
/// </summary>
TooValuable,
@ -84,6 +117,18 @@ public static class VendorSellAcceptability
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
/// </param>
/// <param name="publicWeenieBitfield">
/// F4 (Slice 6b/6c review): the dragged item's own
/// <c>PublicWeenieDesc::_bitfield</c> (<see cref="ClientObject.PublicWeenieBitfield"/>,
/// populated on every ordinary <c>CreateObject</c> — including the
/// player's own pack items, the only things ever dragged here). Tested
/// against <see cref="PublicWeenieFlags.Retained"/>
/// (<c>BF_RETAINED = 0x01000000</c>, acclient.h:6456) — retail's
/// <c>InqAcceptability</c> ORs this bit into the SAME type-mismatch
/// branch (byte 3 bit 0 of the bitfield, <c>pc:005d1aa7</c>), so it
/// folds into the same <see cref="VendorSellRejection.WrongType"/>
/// outcome, not a distinct rejection reason.
/// </param>
public static VendorSellRejection Evaluate(
bool ownedByPlayer,
int containedItemCount,
@ -91,21 +136,34 @@ public static class VendorSellAcceptability
int perUnitValue,
uint merchandiseItemTypes,
uint merchandiseMinValue,
uint merchandiseMaxValue)
uint merchandiseMaxValue,
uint publicWeenieBitfield = 0u)
{
if (!ownedByPlayer)
return VendorSellRejection.NotOwnedByPlayer;
if (containedItemCount > 0)
return VendorSellRejection.None;
if ((itemTypeMask & merchandiseItemTypes) == 0u)
// F4: InqAcceptability's first check ORs the type-mask mismatch
// with the BF_RETAINED bit (pc:005d1aa7) -- both branches return
// the SAME raw item_types value, so both fold into WrongType here.
bool retained = (publicWeenieBitfield & (uint)PublicWeenieFlags.Retained) != 0u;
if ((itemTypeMask & merchandiseItemTypes) == 0u || retained)
return VendorSellRejection.WrongType;
if (perUnitValue == 0)
return VendorSellRejection.NoValue;
if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue)
return VendorSellRejection.TooValuable;
{
// F3 (byte-verified at 0x005d1add) -- see TooValuable's own doc
// comment: a PromissoryNote (trade note) is EXEMPT from the
// max-value rejection (and, by the disassembly's early ret,
// from the min-value check too), not just capped differently.
return (itemTypeMask & (uint)ItemType.PromissoryNote) != 0u
? VendorSellRejection.None
: VendorSellRejection.TooValuable;
}
if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue)
return VendorSellRejection.TooCheap;

View file

@ -16,8 +16,38 @@ public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity);
/// 6b/6c research doc's open question #2); this is the one generic list both
/// tab controllers own an instance of, rather than two near-duplicate types.
/// </summary>
/// <summary>Outcome of <see cref="VendorStagingList.Add"/> — see its doc comment.</summary>
public enum VendorStagingAddOutcome
{
/// <summary>Staged: a new entry was appended, or an existing one accumulated.</summary>
Added,
/// <summary>
/// Retail's 5000-unit cap (<c>0x1388</c>) on an ACCUMULATED total would
/// be exceeded — rejected, the staged entry is unchanged. Show
/// <see cref="VendorStagingList.TooMuchMessage"/>.
/// </summary>
Capped,
/// <summary>Defensive no-op (zero guid or non-positive quantity) — never a retail-modeled path; do not message.</summary>
Ignored,
}
public sealed class VendorStagingList
{
/// <summary>
/// Retail's cap on a staged entry's ACCUMULATED total — <c>0x1388</c>
/// (5000), <c>VendorItemsUI::AddToBuyList</c>'s comparison
/// (<c>pc:202936</c>, <c>0x004c3e73</c>).
/// </summary>
public const int MaxStagedQuantity = 0x1388;
/// <summary>
/// Retail's exact over-cap notice, read from the decompiled binary's
/// data segment at <c>0x007b59d8</c> (<c>VendorItemsUI::AddToBuyList</c>,
/// <c>pc:202938-202949</c>).
/// </summary>
public const string TooMuchMessage =
"I can't possibly sell you that much! Please be a little more reasonable.";
private readonly List<VendorStagingEntry> _entries = new();
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
@ -27,28 +57,40 @@ public sealed class VendorStagingList
/// <summary>
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
/// (Slice 6b/6c research doc §Q3): stages <paramref name="quantity"/>
/// units of <paramref name="itemGuid"/>. Re-adding an already-staged guid
/// (e.g. pressing "Add to List" again after moving the slider) UPSERTS
/// the entry to the new quantity rather than appending a duplicate row —
/// retail's own <c>RemoveProfileFromList</c> looks up an entry BY GUID
/// (a single match), which only holds if <c>AddToBuyList</c> never
/// produces two rows for the same guid; the decomp excerpt available to
/// this port does not show the insert side of that invariant directly,
/// so this is a deliberate, documented inference from the removal side's
/// single-match contract, not a byte-verified citation.
/// (<c>pc:202884-202989</c>, <c>0x004c3dc0</c> — read in full for the
/// Slice 6b/6c review; the insert side is byte-verified below, not
/// inferred). Re-adding an already-staged guid (e.g. pressing "Add to
/// List" again after moving the slider) ACCUMULATES
/// <paramref name="quantity"/> onto the entry's EXISTING staged amount
/// (<c>eax_3 = arg3 + var_9c</c>, <c>pc:202934</c>) — NOT an upsert/
/// overwrite, the shape a prior version of this port used before this
/// review. A brand-new entry (no existing match) is inserted at
/// <paramref name="quantity"/> directly with NO cap check
/// (<c>label_4c3e20</c>, <c>pc:202895-202923</c>) — the 5000-unit cap
/// only guards the ACCUMULATE branch. Exceeding the cap on an
/// accumulate shows <see cref="TooMuchMessage"/> and leaves the entry
/// COMPLETELY UNCHANGED (no partial accumulate, no shop-row effect —
/// <c>pc:202936-202951</c> jumps straight past both).
/// </summary>
public void Add(uint itemGuid, int quantity)
public VendorStagingAddOutcome Add(uint itemGuid, int quantity)
{
if (itemGuid == 0u || quantity <= 0)
return;
return VendorStagingAddOutcome.Ignored;
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
if (index >= 0)
_entries[index] = new VendorStagingEntry(itemGuid, quantity);
{
int total = _entries[index].Quantity + quantity;
if (total > MaxStagedQuantity)
return VendorStagingAddOutcome.Capped;
_entries[index] = new VendorStagingEntry(itemGuid, total);
}
else
{
_entries.Add(new VendorStagingEntry(itemGuid, quantity));
}
Changed?.Invoke();
return VendorStagingAddOutcome.Added;
}
/// <summary>