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

@ -231,18 +231,32 @@ internal sealed class SelectionInteractionController
// TurnToObject branch, Use keeps sending immediately either way (the
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
// comment: "consume the strict 0.2-second gate, send immediately").
if (_query.TryGetApproach(serverGuid, out InteractionApproach approach)
//
// F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself
// gates on (ownedByPlayer || useable) is computed ONCE, up front,
// and checked BEFORE BeginApproach — a prior version of this method
// called BeginApproach unconditionally whenever the target was out
// of close range, kicking off a client-predicted walk toward a
// target the dispatch below was always going to refuse anyway (a
// wasted, visually confusing approach with no possible Use at the
// end of it). Reordering does not change the dispatch itself: an
// ELIGIBLE target still sends immediately, in the same order,
// exactly as before.
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
if (useable
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
&& !approach.IsCloseRange)
{
_movement.BeginApproach(approach);
}
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse(
serverGuid,
ownedByPlayer,
ownedByPlayer || _query.IsUseable(serverGuid),
useable,
reservation,
_transport,
out uint sequence);

View file

@ -303,12 +303,14 @@ public sealed class ItemInteractionController : IDisposable
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
/// sends a multi-item Buy in a single wire call. Rides the SAME
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
/// Retail's client-side affordability/pack-capacity pre-checks
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported
/// here either, for the same reason <see cref="TryBuy"/>'s own doc
/// comment already gives for the single-item Buy: the server is
/// authoritative either way (register AP-162, extended to this batched
/// path rather than filing a second near-duplicate row).
/// F1 (Slice 6b/6c review): retail's four client-side affordability/
/// pack-capacity pre-checks (<c>pc:204017/204032/204053/204067</c>) are
/// now ported, but live in the CALLER —
/// <c>VendorUiController.BuyAllButtonPressed</c> — ahead of this
/// method, not inside it, since they need the staged entries' prices
/// and the player's live capacity, neither of which this method
/// otherwise touches. <see cref="TryBuy"/>'s single-item path still has
/// none (register AP-162, narrowed to that one remaining case).
/// </summary>
public bool TryBuyAll(
uint vendorGuid,

View file

@ -363,6 +363,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
// case only opens a NEW one when this is 0 (pc:204155).
private uint _closeConfirmContext;
// F5: see DragOverGlobalTimeSink's own doc comment.
private readonly DragOverGlobalTimeSink _dragOverSink;
private bool _disposed;
private VendorUiController(
@ -501,6 +503,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// target. UiItemList.RegisterDragHandler is the structural analogue.
_sellingList?.RegisterDragHandler(this);
// F5: mount the global-time sink so a live drag hovering anywhere
// over this window auto-switches to the Selling tab — see
// DragOverGlobalTimeSink's and PollDragOver's own doc comments.
_dragOverSink = new DragOverGlobalTimeSink(PollDragOver);
_window.ContentRoot.AddChild(_dragOverSink);
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
// (UiMenu draws nothing without SpriteResolve — see the popup
// geometry class doc above) and the vendor-authored popup geometry
@ -586,6 +594,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_sellClearListButton.OnClick = () => _sellStaging.Clear();
_buyStaging.Changed += RebuildBuyingList;
// F2 (Slice 6b/6c review): the Items tab's own row visibility must
// track staging too — see RefreshItemsTabAvailability's doc.
_buyStaging.Changed += RefreshItemsTabAvailability;
_sellStaging.Changed += RebuildSellingList;
ShowTab(VendorPanelTab.Items);
@ -685,9 +696,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// non-empty (Q3's close-button finding). Optional — absent gracefully
// degrades the close gate to a plain hide (see CloseButtonPressed).
RetailDialogFactory? dialogs = null,
// Slice 6b/6c: InqAcceptability rejection strings + retail's two
// Buy-All affordability/capacity transient errors (deliberately not
// ported, register AP-162) share this sink.
// Slice 6b/6c: InqAcceptability rejection strings, the Buy-All
// affordability/capacity pre-send guards (F1, Slice 6b/6c review),
// and the buy/sell staging notices all share this one sink.
Action<string>? systemMessage = null)
{
ArgumentNullException.ThrowIfNull(layout);
@ -782,6 +793,26 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
private enum VendorPanelTab { Items, Buying, Selling }
/// <summary>
/// F5 (Slice 6b/6c review): a runtime-only, zero-size, always-invisible-
/// to-hit-testing helper that opts this window into retail's global UI
/// message 3 — <c>gmVendorUI::ListenToGlobalMessage</c> (<c>0x004c0480</c>):
/// <c>if (arg2 == 3) gmVendorUI::UpdateDragOver(this);</c>. This
/// controller is not itself a <see cref="UiElement"/> (it wraps
/// several), so it cannot directly implement <see cref="IUiGlobalTimeListener"/>
/// the way <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s
/// broadcast walks the ELEMENT tree, not arbitrary controllers. Mounting
/// this tiny sink as a child of the window gives it the same periodic
/// pulse retail's own <c>UIElementManager::UseTime</c> delivers, without
/// adding a second per-frame plumbing path.
/// </summary>
private sealed class DragOverGlobalTimeSink : UiElement, IUiGlobalTimeListener
{
private readonly Action _onGlobalUiTime;
public DragOverGlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime();
}
private void ShowTab(VendorPanelTab tab)
{
_itemsPage.Visible = tab == VendorPanelTab.Items;
@ -841,6 +872,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
ClearContent();
ShowTab(VendorPanelTab.Items);
_window.Hide();
// F8 (Slice 6b/6c review): a live X-close confirmation
// dialog must not survive the session it was confirming
// the abandonment of — a range-triggered Close() or a
// portal/logout Reset() while the dialog is up left it
// dangling (a stale callback capturing this disposed
// controller's state) before this fix.
DismissCloseConfirmationIfOpen();
break;
}
}
@ -946,7 +984,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// field, so the toolbar status bar and slider light up for the
/// auto-selected item too.
/// </remarks>
private void RebuildItemList()
private void RebuildItemList() => RebuildItemList(reselectFirst: true);
/// <param name="reselectFirst">
/// F2 (Slice 6b/6c review): <see langword="true"/> for every ORIGINAL
/// call site (category rebuild — Opened/Refreshed/manual category
/// switch, retail's notify=1 case per the remarks above).
/// <see langword="false"/> for the NEW staging-triggered repaint this
/// review added (<see cref="RefreshItemsTabAvailability"/>) — retail's
/// <c>RemoveFromShop</c>/<c>DeleteItem</c> (the function that actually
/// hides/shrinks a row as staging consumes it) never reselects to the
/// first item; only a full <c>UpdateItemsList</c> rebuild does that, and
/// staging a Buy does not trigger one.
/// </param>
private void RebuildItemList(bool reselectFirst)
{
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? _presentCategories[_selectedCategoryIndex].Mask
@ -963,6 +1014,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// Changed to correct it).
uint? selectedGuid = _selection.SelectedObjectId;
VendorShopItem? firstItem = null;
bool selectedStillVisible = false;
using (_itemList.DeferLayout())
{
@ -972,8 +1024,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
foreach (VendorShopItem item in items)
{
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
// F2 (Slice 6b/6c review): a shop item fully consumed by
// staging hides its row — port of
// VendorItemsUI::RemoveFromShop's DeleteItem branch
// (pc:202846-202852) — see AvailableShopQuantity's own
// doc comment.
if (AvailableShopQuantity(item) <= 0) continue;
firstItem ??= item;
if (item.ItemGuid == selectedGuid) selectedStillVisible = true;
// F5 (Slice 5.4 review): forward the icon underlay/
// overlay/effects PublicWeenieDescParser already
@ -1004,21 +1063,68 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
}
}
// F6: unconditional — no survival test. Every rebuild call site
// (Opened/Refreshed via RebuildCategories, and a manual category
// switch via SelectCategory) is retail's notify=1 case; see the
// remarks above for the decomp trace.
if (firstItem is { } first)
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
else
_selection.Clear(SelectionChangeSource.Vendor);
if (reselectFirst)
{
// F6: unconditional — no survival test. Every rebuild call site
// (Opened/Refreshed via RebuildCategories, and a manual category
// switch via SelectCategory) is retail's notify=1 case; see the
// remarks above for the decomp trace.
if (firstItem is { } first)
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
else
_selection.Clear(SelectionChangeSource.Vendor);
// F7a: unconditional scroll-to-start on every rebuild (retail only
// guards on the list being non-empty; resetting an already-empty
// list's scroll is harmless).
_itemList.Scroll.SetScrollY(0);
// F7a: unconditional scroll-to-start on every rebuild (retail only
// guards on the list being non-empty; resetting an already-empty
// list's scroll is harmless).
_itemList.Scroll.SetScrollY(0);
}
else if (selectedGuid is not null && !selectedStillVisible)
{
// F2: the currently-selected row just disappeared (staging
// consumed its last unit) — matches RemoveFromShop's own
// unconditional SetSelectedObject(0, 0) on the delete path
// (pc:202848-202850), NOT a reselect-to-first.
_selection.Clear(
SelectionChangeSource.Vendor,
SelectionChangeReason.SelectedObjectRemoved);
}
}
/// <summary>
/// F2 (Slice 6b/6c review): retail's <c>RemoveFromShop</c>
/// (<c>0x004c3ce0</c>) recomputes "shop remaining = ORIGINAL vendor
/// supply - the item's CURRENT total staged amount" fresh on every call
/// (<c>pc:202844</c>: <c>_maxStackSize_1 = var_c - arg3</c>, where
/// <c>var_c</c> is read fresh from the untouched <c>shopItemProfileList</c>
/// and <c>arg3</c> is <c>AddToBuyList</c>'s own running accumulated
/// total) rather than incrementally decrementing a mutable counter.
/// This mirrors that shape exactly: <see cref="VendorState.Items"/>
/// (the untouched <c>ApproachVendor</c> snapshot) combined with
/// <see cref="_buyStaging"/>'s current total, recomputed on every call
/// — so un-staging automatically restores a hidden/shrunk row with no
/// separate "restore" code path. <see cref="int.MaxValue"/> for
/// unlimited supply (<see cref="VendorShopItem.StackSize"/> == -1,
/// retail's <c>ItemProfile</c> sign-extended unlimited sentinel) —
/// retail's own <c>var_c != 0xffffffff</c> guard skips the whole
/// decrement/delete branch for unlimited stock.
/// </summary>
private int AvailableShopQuantity(VendorShopItem item)
{
if (item.StackSize < 0)
return int.MaxValue;
int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0;
return item.StackSize - staged;
}
/// <summary>
/// F2 (Slice 6b/6c review): repaints the Items tab's row visibility
/// whenever <see cref="_buyStaging"/> changes, WITHOUT the
/// reselect-to-first behavior a full category rebuild performs — see
/// <see cref="RebuildItemList(bool)"/>'s <c>reselectFirst</c> doc.
/// </summary>
private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
/// <summary>
/// Port of retail row selection display —
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
@ -1115,6 +1221,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
{
_ = transition;
RefreshSelectionDisplay();
// F9 (Slice 6b/6c review): a click on an already-staged Buying/
// Selling row calls SelectionState.Select the same way an Items-tab
// row does (RebuildBuyingList/RebuildSellingList's own Clicked
// handlers), but those two lists are only ever REBUILT when their
// OWN staging list changes -- a pure selection change (no staging
// mutation) never repainted their cell.Selected flags, so the
// highlight silently failed to move onto a staged row. Update both
// strips' highlight in place on every selection transition, no
// matter which panel originated it -- mirrors RefreshSelectionDisplay's
// own "react to ANY global selection change" shape.
RefreshStagingSelectionHighlight();
}
/// <summary>See <see cref="OnSelectionTransition"/>'s F9 note.</summary>
private void RefreshStagingSelectionHighlight()
{
uint? selected = _selection.SelectedObjectId;
SetHighlight(_buyingList, selected);
SetHighlight(_sellingList, selected);
static void SetHighlight(UiItemList? list, uint? selectedGuid)
{
if (list is null) return;
for (int i = 0; i < list.GetNumUIItems(); i++)
{
if (list.GetItem(i) is { } cell)
cell.Selected = cell.ItemId == selectedGuid;
}
}
}
/// <summary>
@ -1158,6 +1293,33 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// (Slice 6.1) is therefore what actually drives "vendor session close
/// clears a vendor-owned selection."
/// </summary>
/// <summary>
/// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession
/// sites, both reachable through this SAME <see cref="ClientObjectTable.ObjectRemoved"/>
/// notification in this architecture.
/// <para>
/// <b>Sell side</b> — <c>gmVendorUI::RecvNotice_ServerSaysMoveItem</c>
/// (<c>0x004c44a0</c>): a staged SELL item silently unstages once
/// <c>ACCWeenieObject::IsOwnedByPlayer</c> goes false for it (no notice
/// shown). The closest reachable proxy here is "the item left
/// <see cref="ClientObjectTable"/> entirely" — a strictly narrower
/// trigger than retail's "moved to ANY non-player container," but the
/// only one an item leaving the table for real (destroyed, traded away
/// and never re-registered, sold through a different path) reaches.
/// </para>
/// <para>
/// <b>Buy side</b> — <c>VendorItemsUI</c>'s shop-list-removal notice
/// (<c>0x004c4246</c>, inside <c>gmVendorUI::HandleMousePresses</c>,
/// <c>pc:203165</c>: <c>"Removing %s from shopping list"</c>). A staged
/// BUY guid that drops out of the vendor's CURRENT stock (sold out by
/// someone else, or a different vendor superseded this session) is
/// retired from <see cref="ClientObjectTable"/> by
/// <c>VendorShopItemMaterializer</c>'s own retire pass on every
/// Opened/Refreshed/Closed/Reset transition — which fires this SAME
/// event, giving one unified site for both retail sources instead of a
/// separate "diff the vendor's item list" listener.
/// </para>
/// </summary>
private void OnObjectRemoved(ClientObject item)
{
if (_selection.SelectedObjectId == item.ObjectId)
@ -1166,6 +1328,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SelectionChangeSource.Vendor,
SelectionChangeReason.SelectedObjectRemoved);
}
// Sell side: silent, matching retail's own RecvNotice_ServerSaysMoveItem.
_sellStaging.Remove(item.ObjectId, -1);
// Buy side: retail's exact "Removing %s from shopping list" notice.
if (_buyStaging.Remove(item.ObjectId, -1))
{
string name = string.IsNullOrWhiteSpace(item.Name) ? "that item" : item.Name;
_systemMessage?.Invoke($"Removing {name} from shopping list");
}
}
/// <summary>
@ -1350,6 +1522,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// SAME quantity computation the display and Buy button already share
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
/// F2 (Slice 6b/6c review): re-adding an already-staged item ACCUMULATES
/// rather than overwrites, and retail's 5000-unit cap on that
/// accumulate shows <see cref="VendorStagingList.TooMuchMessage"/> and
/// leaves the entry unchanged — see <see cref="VendorStagingList.Add"/>.
/// </summary>
private void AddSelectedToBuyList()
{
@ -1357,7 +1533,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return;
uint quantity = ResolveBuyQuantity(shopItem);
_buyStaging.Add(shopItem.ItemGuid, (int)quantity);
if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped)
_systemMessage?.Invoke(VendorStagingList.TooMuchMessage);
}
/// <summary>
@ -1402,16 +1579,64 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
}
}
/// <summary>
/// Retail's exact affordability-failure notice — read from the
/// decompiled binary's data segment at <c>0x007b57b4</c> (BuyAllButtonPressed's
/// two guards below share this SAME string; see the class doc).
/// </summary>
private const string NotEnoughMoneyMessage = "You don't have enough money";
/// <summary>
/// Retail's exact capacity-failure notice — read from the decompiled
/// binary's data segment at <c>0x007b5750</c> (BuyAllButtonPressed's
/// two capacity guards below share this SAME string; see the class doc).
/// </summary>
private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first";
/// <summary>
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
/// (<c>pc:204011-204079</c>). Sends every staged entry as ONE batched
/// Buy (<see cref="ItemInteractionController.TryBuyAll"/>). Retail's
/// client-side affordability/pack-capacity pre-checks
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported —
/// see <c>TryBuyAll</c>'s own doc comment (register AP-162, extended
/// rather than duplicated). On a successful DISPATCH the whole staged
/// list is flushed UNCONDITIONALLY and immediately, matching retail's
/// literal order: <c>SendShopEvent(...)</c> then
/// (<c>pc:204011-204079</c>, <c>0x004c5388</c>). Sends every staged
/// entry as ONE batched Buy (<see cref="ItemInteractionController.TryBuyAll"/>).
/// <para>
/// F1 (Slice 6b/6c review): retail's FOUR client-side pre-send guards
/// are ported here, in order, each returning with staging fully
/// intact on failure (amends AP-162's "no client-side pre-check"
/// claim — see the register):
/// </para>
/// <list type="number">
/// <item>pyreal affordability — transaction total vs. purse
/// (<c>pc:204017</c>: <c>m_transactionValue &lt;= m_totalValue</c>).</item>
/// <item>alt-currency affordability — vs. held trade currency minus
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
/// so this uses the vendor's raw held count, retail's own
/// <c>m_last_sale == 0</c> case.</item>
/// <item>container-slot capacity (<c>pc:204053</c>:
/// <c>containerSlotsNeeded &gt; player.ContainersCapacity - containersUsed</c>).</item>
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
/// item slots).</item>
/// </list>
/// Both affordability guards share <see cref="NotEnoughMoneyMessage"/>;
/// both capacity guards share <see cref="NotEnoughRoomMessage"/> —
/// retail's own two distinct <c>StringInfo</c> literal sites collapse
/// to exactly these two strings (<c>pc:204020</c>/<c>204034</c> both
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
/// <para>
/// <b>Container-vs-item slot classification (register AP-168).</b>
/// Retail's own split tests a bitfield bit this codebase does not
/// currently thread onto <see cref="VendorShopItem"/>
/// (<c>gmVendorUI::InqListSlotCount</c>, <c>pc:200038-200065</c>) — this
/// port approximates "is this shop item a container" with
/// <see cref="ItemType.Container"/> instead, correct for the ordinary
/// case (a real backpack/pouch DOES carry that type bit) but not
/// byte-identical for the theoretical case of a non-<c>Container</c>-typed
/// item that still authors nonzero pack/side capacities. See the
/// register.
/// </para>
/// On a successful DISPATCH the whole staged list is flushed
/// UNCONDITIONALLY and immediately, matching retail's literal order:
/// <c>SendShopEvent(...)</c> then
/// <c>PackableList::Flush(&amp;m_buyList)</c> (<c>pc:204075-204076</c>) —
/// the clear happens right after the send, not gated on the eventual
/// server response/UseDone.
@ -1425,10 +1650,105 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
foreach (VendorStagingEntry entry in _buyStaging.Entries)
items.Add((entry.Quantity, entry.ItemGuid));
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid))
VendorShopProfile profile = _vendor.Profile;
int transactionValue = ComputeBuyTransactionValue();
// Guards 1/2: pyreal vs. alt-currency affordability.
if (profile.AlternateCurrencyWcid == 0u)
{
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
if (transactionValue > playerTotal)
{
_systemMessage?.Invoke(NotEnoughMoneyMessage);
return;
}
}
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
{
_systemMessage?.Invoke(NotEnoughMoneyMessage);
return;
}
// Guards 3/4: container-slot then item-slot capacity.
(int itemSlotsNeeded, int containerSlotsNeeded) = ComputeBuySlotsNeeded(items);
ClientObject? player = _objects.Get(_playerGuid());
(int itemsUsed, int containersUsed) = CountPlayerContents();
int freeContainerSlots = (player?.ContainersCapacity ?? 0) - containersUsed;
if (containerSlotsNeeded > freeContainerSlots)
{
_systemMessage?.Invoke(NotEnoughRoomMessage);
return;
}
int freeItemSlots = (player?.ItemsCapacity ?? 0) - itemsUsed;
if (itemSlotsNeeded > freeItemSlots)
{
_systemMessage?.Invoke(NotEnoughRoomMessage);
return;
}
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
_buyStaging.Clear();
}
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
private int ComputeBuyTransactionValue()
{
VendorShopProfile profile = _vendor.Profile;
int total = 0;
foreach (VendorStagingEntry entry in _buyStaging.Entries)
{
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem item))
continue;
int perUnit = VendorPricing.PerUnitValue(item.Value ?? 0, item.DescStackSize);
total += VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, entry.Quantity);
}
return total;
}
/// <summary>
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
/// comment for the container-classification approximation.
/// </summary>
private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded(
IReadOnlyList<(int Amount, uint ItemGuid)> items)
{
int itemSlots = 0, containerSlots = 0;
foreach ((int amount, uint guid) in items)
{
if (!TryFindShopItem(guid, out VendorShopItem item))
continue;
bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u;
bool stackable = (item.DescStackSize ?? 1) > 1;
if (stackable)
{
if (isContainer) containerSlots += 1; else itemSlots += 1;
}
else
{
if (isContainer) containerSlots += amount; else itemSlots += amount;
}
}
return (itemSlots, containerSlots);
}
/// <summary>F1: the player's CURRENT occupied item/container slot counts.</summary>
private (int Items, int Containers) CountPlayerContents()
{
int items = 0, containers = 0;
foreach (uint guid in _objects.GetContents(_playerGuid()))
{
ClientObject? obj = _objects.Get(guid);
bool isContainer = obj is not null
&& (obj.ItemsCapacity > 0
|| obj.ContainersCapacity > 0
|| (obj.Type & ItemType.Container) != 0);
if (isContainer) containers++; else items++;
}
return (items, containers);
}
/// <summary>
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
@ -1446,25 +1766,74 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_buyStaging.Remove(guid, amount);
}
/// <summary>
/// Retail's exact refusal for a partial-stack Sell Item attempt,
/// read from the decompiled binary's data segment at
/// <c>0x007b57ec</c> (<c>gmVendorUI::SellSingleItem</c>,
/// <c>pc:201860-201864</c>).
/// </summary>
private const string CannotSellPartialStackMessage = "Cannot sell part of a stack";
/// <summary>
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
/// (<c>pc:204101-204112</c>). Sells the globally-selected staged item
/// immediately (a one-entry <see cref="ItemInteractionController.TrySell"/>
/// list using the entry's OWN staged quantity — Sell has no separate
/// global-slider read the way Buy does), then on a successful dispatch
/// removes the ENTIRE staged entry unconditionally — retail always
/// passes <c>0xffffffff</c> here (<c>pc:204108</c>), unlike the Buy
/// side's stackable-conditional amount.
/// (<c>pc:204101-204112</c>), calling <c>gmVendorUI::SellSingleItem</c>
/// (<c>pc:201808-201881</c>, <c>0x004c2b40</c>).
/// <para>
/// F13 (Slice 6b/6c review): retail reads <c>ACCWeenieObject::selectedID</c>
/// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid
/// actually staged" requirement at all. A prior version of this port
/// required a matching <see cref="_sellStaging"/> entry first; that
/// gate does not exist in retail (you can Sell Item something you
/// never dragged onto the Selling tab, exactly like the Items tab's
/// own single-item Buy button operates on the selection with no
/// staging requirement either).
/// </para>
/// <para>
/// F6 (Slice 6b/6c review, byte-verified): <c>SellSingleItem</c>
/// refuses a PARTIAL stack — the selected item's own split slider must
/// show the FULL stack (or the item must be non-stackable), else it
/// shows <see cref="CannotSellPartialStackMessage"/> and sends NOTHING
/// (<c>pc:201833-201864</c>: <c>_stackSize&lt;=1 || splitSize&gt;=maxSplitSize</c>
/// gates the send). On success it sends amount <c>1</c> LITERALLY
/// (<c>var_9c = 1</c>, <c>pc:201838</c>) — not the stack size — matching
/// retail's own send exactly rather than <see cref="VendorStagingEntry.Quantity"/>.
/// This method does NOT re-run <see cref="EvaluateSellAcceptability"/>'s
/// ownership/type/value gate — retail's own <c>SellSingleItem</c>
/// doesn't either at this call site (that gate is drag-time only,
/// <c>VendorSellUI::DragItemAcceptable</c>); the server remains
/// authoritative for a selection that was never legitimately
/// draggable.
/// </para>
/// <para>
/// Retail's own non-empty-container refusal branch inside
/// <c>SellSingleItem</c> (<c>pc:201818-201829</c>, a container-type
/// item with contents currently blocks a Sell Item attempt on the
/// CONTAINER itself with a distinct message) is NOT ported here — see
/// the register.
/// </para>
/// On a successful dispatch the item's own staged entry (if any) is
/// still removed unconditionally, mirroring retail's own
/// <c>RemoveProfileFromList(&amp;m_sellList, selectedID, 0xffffffff)</c>
/// (<c>pc:204108</c>), which runs regardless of whether the sold item
/// was ever actually staged.
/// </summary>
private void SellItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid
|| !_sellStaging.TryGet(guid, out VendorStagingEntry entry))
{
if (_selection.SelectedObjectId is not { } guid || _objects.Get(guid) is not { } item)
return;
uint fullStack = (uint)Math.Max(1, item.StackSize);
if (fullStack > 1)
{
uint live = _splitQuantity.GetObjectSplitSize(guid, guid, fullStack);
if (live < fullStack)
{
_systemMessage?.Invoke(CannotSellPartialStackMessage);
return;
}
}
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) }))
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (1, guid) }))
_sellStaging.Remove(guid, -1);
}
@ -1617,6 +1986,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
: ItemDragAcceptance.Reject;
}
/// <summary>
/// F5 (Slice 6b/6c review): port of <c>gmVendorUI::UpdateDragOver</c>
/// (<c>0x004c03b0</c>, <c>pc:199542-199553</c>), driven by
/// <see cref="DragOverGlobalTimeSink"/>'s periodic pulse. While the
/// window is visible, the Selling page is NOT already the open one, and
/// a drag is currently live anywhere in the whole UI (retail's
/// <c>UIElementManager::s_pInstance-&gt;m_dragElement != 0</c>, here
/// <see cref="UiRoot.DragSource"/>), retail opens the Selling tab the
/// instant the pointer enters the WINDOW'S bounds — not any specific
/// list — making <see cref="_sellingList"/> a reachable drop target
/// without the player manually clicking the tab first.
/// <para>
/// <b>Citation correction.</b> Retail's own guard token is
/// <c>m_OpenPageToken != 0x100000cd</c> — the review that flagged this
/// finding described <c>0x100000cd</c> as "the Buying page," but this
/// controller's own <see cref="SellingPageId"/> constant is
/// <c>0x100000CD</c>, not <see cref="BuyingPageId"/>
/// (<c>0x100000C4</c>). The guard is "don't reopen the tab you're
/// already on," checked against the SELLING page specifically —
/// <see cref="_sellingPage"/>'s own <c>Visible</c> flag is the exact
/// analogue.
/// </para>
/// </summary>
private void PollDragOver()
{
if (_sellingPage.Visible) return;
if (!_window.IsVisible) return;
UiRoot? root = _itemsPage.FindRoot();
if (root?.DragSource is null) return;
System.Numerics.Vector2 pos = _window.OuterFrame.ScreenPosition;
float x0 = pos.X, y0 = pos.Y;
float x1 = x0 + _window.OuterFrame.Width, y1 = y0 + _window.OuterFrame.Height;
if (root.MouseX > x0 && root.MouseX < x1 && root.MouseY > y0 && root.MouseY < y1)
ShowTab(VendorPanelTab.Selling);
}
/// <summary>
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
@ -1652,19 +2059,21 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// fed from the dragged item's own <see cref="ClientObject"/> state and
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
/// <paramref name="quantity"/> is the staged quantity a successful drop
/// would use: 1 for a non-stack item, else the LIVE split-quantity
/// slider value for the dragged item — the SAME read
/// <c>ExternalContainerController.HandleDropRelease</c> already uses
/// (<c>StackSplitQuantityState.GetObjectSplitSize</c> only returns the
/// slider value for the CURRENTLY selected item; both
/// <c>InventoryController</c> and <c>PaperdollController</c> already
/// select the dragged item on LIFT — <c>ItemList_BeginDrag @
/// 0x004E32D0</c> — so by the time a drop reaches here the dragged item
/// is already the global selection in the realistic flow). The Slice
/// 6b/6c research doc's Q4 section flags this exact quantity source as
/// an inferred analogy to the Buying tab's <c>AddToBuyList</c>, not a
/// byte-verified citation for the Selling side specifically — see
/// <c>VendorStagingList.Add</c>'s own doc comment.
/// would use.
/// <para>
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
/// argument, never a slider read. A prior version of this port read the
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
/// doc's Q4 section had flagged this exact source as an unverified
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
/// inference is now known WRONG: Sell staging has no partial-quantity
/// feature in retail at all, unlike Buy. See
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
/// (genuinely slider-driven) contrast.
/// </para>
/// </summary>
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
{
@ -1683,15 +2092,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
perUnitValue,
_vendor.Profile.MerchandiseItemTypes,
_vendor.Profile.MerchandiseMinValue,
_vendor.Profile.MerchandiseMaxValue);
_vendor.Profile.MerchandiseMaxValue,
item.PublicWeenieBitfield ?? 0u);
if (rejection == VendorSellRejection.None)
{
uint fullStack = (uint)Math.Max(1, item.StackSize);
quantity = fullStack > 1
? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack)
: 1;
}
quantity = (int)Math.Max(1, item.StackSize);
return rejection;
}
@ -1737,8 +2142,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// which preserves the player's category selection) instead of a full
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
/// </remarks>
/// <summary>
/// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's
/// declared string length (<c>0x53</c> wchar16) truncates the literal
/// mid-sentence, but the raw bytes immediately following it
/// (<c>0x007b5c7e</c>) are <c>3f 00</c> — UTF-16LE for <c>'?'</c> —
/// before the null terminator. Retail's data segment carries a trailing
/// question mark this port previously dropped.
/// </summary>
private const string CloseConfirmationMessage =
"You have not completed all transactions. Are you sure you want to leave this vendor";
"You have not completed all transactions. Are you sure you want to leave this vendor?";
private void CloseButtonPressed()
{
@ -1763,6 +2176,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
});
}
/// <summary>
/// F8 (Slice 6b/6c review): shared by <see cref="Dispose"/> and the
/// session Closed/Reset arm of <see cref="OnVendorChanged"/> — a live
/// X-close confirmation dialog must not outlive the controller or the
/// vendor session it was asking about.
/// </summary>
private void DismissCloseConfirmationIfOpen()
{
if (_closeConfirmContext == 0u)
return;
_dialogs?.CloseDialog(_closeConfirmContext);
_closeConfirmContext = 0u;
}
private void ClearContent()
{
_presentCategories.Clear();
@ -1830,12 +2257,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_buyStaging.Changed -= RebuildBuyingList;
_buyStaging.Changed -= RefreshItemsTabAvailability;
_sellStaging.Changed -= RebuildSellingList;
if (_closeConfirmContext != 0u)
{
_dialogs?.CloseDialog(_closeConfirmContext);
_closeConfirmContext = 0u;
}
DismissCloseConfirmationIfOpen();
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
RetailTabBinding.SetClick(_itemsTab, null);
RetailTabBinding.SetClick(_buyingTab, null);
RetailTabBinding.SetClick(_sellingTab, null);

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>