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
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:
parent
92ea3977b6
commit
c68ad1e646
11 changed files with 1314 additions and 99 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 && value > 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 >> 16)) & 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 >> 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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue