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

File diff suppressed because one or more lines are too long

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>

View file

@ -411,6 +411,31 @@ public sealed class SelectionInteractionControllerTests
Assert.Empty(h.Transport.Uses);
}
/// <summary>
/// F11 (Slice 6b/6c review): a FAR target the eligibility gate will
/// refuse (not owned by the player, not useable) must never kick off a
/// speculative local approach at all — the prior <c>RequestUse</c>
/// ordering called <c>BeginApproach</c> before the eligibility check,
/// so an out-of-range unusable target still walked the player toward
/// it even though the dispatch immediately below was always going to
/// reject. The existing <see cref="RejectedWorldUseReleasesItsBusyReservation"/>
/// test never configured an approach at all (<c>TryGetApproach</c>
/// returns false unconditionally), so it could not have caught this —
/// this test explicitly combines "far" with "rejected."
/// </summary>
[Fact]
public void FarUnusableTargetIsRejectedWithoutApproaching()
{
var h = new Harness();
h.Query.Useable = false;
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
Assert.Empty(h.Movement.Approaches);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
{

View file

@ -207,6 +207,21 @@ public sealed class VendorUiControllerTests
public Harness()
{
// F1 (Slice 6b/6c review): a realistic player pack shape (102
// main-pack item slots, 7 side-pack/container slots — the
// conventional retail AC main-pack capacity) so the NEW Buy All
// capacity pre-checks have real room to work with; a stub
// ClientObject's default ItemsCapacity/ContainersCapacity=0
// would reject every Buy All. AddOrUpdate FIRST, then
// UpsertProperties SECOND — UpsertProperties mutates an
// EXISTING entry in place rather than replacing it wholesale.
Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
Objects.UpsertProperties(PlayerGuid, bundle);
@ -1385,6 +1400,116 @@ public sealed class VendorUiControllerTests
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
/// <summary>
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
/// already-staged item ACCUMULATES onto the existing staged quantity —
/// a prior version of this port upserted/overwrote. Observed through a
/// subsequent Buy All send (the one path that reads
/// <see cref="VendorStagingEntry.Quantity"/> directly, unlike Buy
/// Item's own live-slider read).
/// </summary>
[Fact]
public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 10u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.SetValue(15u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items);
}
/// <summary>
/// F2: retail's 5000-unit cap (<c>VendorStagingList.MaxStagedQuantity</c>)
/// on an ACCUMULATE rejects with its own notice and leaves the entry
/// unchanged.
/// </summary>
[Fact]
public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.Reset(5000u, initialValue: 20u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// Boost the player's coin total so a follow-up Buy All send is only
// gated by staging content, not F1's affordability guard, proving
// the rejected accumulate above left the entry at its PRE-add value.
h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000;
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(4990, items.Single().Amount);
}
/// <summary>
/// F2 (Slice 6b/6c review): port of <c>VendorItemsUI::RemoveFromShop</c>
/// (<c>0x004c3ce0</c>) — staging the vendor's ENTIRE limited supply of an
/// item hides its Items-tab row (matching retail's <c>DeleteItem</c>),
/// and un-staging restores it — <see cref="AvailableShopQuantity"/> is
/// recomputed fresh from the untouched snapshot each time, so no
/// separate "restore" code path is needed.
/// </summary>
[Fact]
public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
Assert.Equal(1, h.ItemList.GetNumUIItems());
h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available
Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains
h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted
Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden
Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0)
h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything
Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
/// <summary>F2: retail's <c>var_c != 0xffffffff</c> guard — unlimited stock (StackSize == -1) is never hidden.</summary>
[Fact]
public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
for (int i = 0; i < 5; i++)
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.ItemList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
{
@ -1427,6 +1552,90 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.BuyAlls);
}
// ══════════════════════════════════════════════════════════════════════
// F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards.
// Each blocking guard leaves staging fully intact; the existing
// BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess
// test above is the "all four pass" case (it already exercises the
// Harness's 102 item / 7 container capacity headroom).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
// Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor,
// 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default.
h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
{
@ -1483,6 +1692,39 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Buys);
}
/// <summary>
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
/// visibly move the highlight — a prior version of this port only
/// repainted the Buying/Selling strips when their OWN staging list
/// changed, so a pure selection change (clicking a DIFFERENT already-
/// staged row, no staging mutation) left both rows' <c>Selected</c>
/// flags stale.
/// </summary>
[Fact]
public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
// AnotherArmorItemGuid is currently selected+highlighted (last staged).
UiItemSlot firstRow = h.BuyingList.GetItem(0)!;
UiItemSlot secondRow = h.BuyingList.GetItem(1)!;
firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation
Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId);
Assert.True(firstRow.Selected);
Assert.False(secondRow.Selected);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
@ -1546,6 +1788,82 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F5 (Slice 6b/6c review): every drag test above (and
/// <see cref="HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt"/>
/// below) calls <c>OnDragOver</c>/<c>HandleDropRelease</c> DIRECTLY,
/// bypassing <see cref="UiRoot"/>'s real pointer pipeline entirely — the
/// review flagged that this can never exercise the NEW auto-switch
/// behavior (<c>PollDragOver</c>), which reacts to <see cref="UiRoot.DragSource"/>/
/// <see cref="UiRoot.MouseX"/>/<see cref="UiRoot.MouseY"/>, not a direct
/// method call. This test drives the WHOLE thing through
/// <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
/// <see cref="UiRoot.Tick"/>/<see cref="UiRoot.OnMouseUp"/>: lift a drag
/// from an (unrelated) source cell, hover it over the vendor window
/// while the Items tab is still open, let the periodic global-UI-time
/// pulse (retail's message 3) auto-switch to Selling, then release
/// ONTO the now-visible Selling list's real hit-test geometry.
/// </summary>
[Fact]
public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
// The Selling list needs real screen geometry (and at least one
// empty-slot placeholder cell) to be hit-testable — the hand-built
// harness never runs a real DAT-driven layout pass, so this test
// gives it one explicitly rather than relying on production sizing.
// Exactly ONE cell's worth of width so the post-drop repaint has no
// room left for an extra empty-slot placeholder alongside the real
// staged item.
h.SellingList.Width = 32f;
h.SellingList.Height = 32f;
using (h.SellingList.DeferLayout()) { }
// A standalone drag SOURCE cell standing in for "the player's own
// inventory panel" (not modeled by this harness) — positioned well
// outside the vendor window (which occupies roughly (0,0)-(800,110)
// here) so it cannot be confused with any vendor widget.
var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f };
sourceCell.SetItem(PlayerOwnedArmorGuid, 0u);
h.Screen.AddChild(sourceCell);
Assert.True(h.ItemsPage.Visible);
Assert.False(h.SellingPage.Visible);
// Press on the source cell, then move past the 3px promotion
// threshold to somewhere INSIDE the vendor window (but not
// specifically over the Selling list) while the Items tab is still
// showing — mirrors a player dragging toward "the vendor" in
// general before the panel has switched tabs for them.
h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560);
h.Screen.OnMouseMove(400, 50);
Assert.Same(sourceCell, h.Screen.DragSource);
Assert.False(h.SellingPage.Visible);
// F5: the auto-switch happens on the periodic global-UI-time pulse,
// not on the mouse-move itself — retail polls this from
// UpdateDragOver via UI message 3.
h.Screen.Tick(0.016, 1L);
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
// Move onto the now-visible Selling list's real geometry and
// release — UiRoot's own hit-test resolves the drop, not a direct
// HandleDropRelease call.
h.Screen.OnMouseMove(20, 15);
h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15);
Assert.Null(h.Screen.DragSource);
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Empty(h.SystemMessages);
}
[Fact]
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
{
@ -1564,6 +1882,32 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
/// LITERAL <c>-1</c> "full stack" argument
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
/// slider read. A prior version of this port read the LIVE split
/// slider here instead — this proves a PARTIAL slider selection at
/// drop time does not leak into the staged (or sent) quantity.
/// </summary>
[Fact]
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
h.SellAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void HandleDropRelease_WrongTargetList_IsIgnored()
{
@ -1631,6 +1975,35 @@ public sealed class VendorUiControllerTests
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c> is now checked end to
/// end — an item whose TYPE matches the vendor's merchandise mask is
/// still rejected (as the generic <c>WrongType</c> message, matching
/// retail's OR'd branch) when its bitfield carries the bit.
/// </summary>
[Fact]
public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerOwnedArmorGuid,
Name = "Heirloom Chainmail",
Type = ItemType.Armor,
Value = 100,
StackSize = 1,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained,
});
h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
}
[Fact]
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
{
@ -1680,6 +2053,71 @@ public sealed class VendorUiControllerTests
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
/// <summary>
/// F13 (Slice 6b/6c review): retail's Sell Item reads
/// <c>ACCWeenieObject::selectedID</c> UNCONDITIONALLY — there is no
/// "must be staged first" requirement. A prior version of this port
/// required a matching <c>_sellStaging</c> entry, which this test would
/// have failed against (no drop/staging happens here at all — only a
/// direct global selection).
/// </summary>
[Fact]
public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor);
Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped
h.SellItemButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified <c>pc:201833-201864</c>):
/// <c>SellSingleItem</c> refuses a stackable item whose split slider is
/// not showing the FULL stack — shows the exact retail notice and sends
/// nothing.
/// </summary>
[Fact]
public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20
h.SellItemButton.OnClick!.Invoke();
Assert.Empty(h.Sells);
Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages);
}
/// <summary>
/// F6: once the slider shows the FULL stack, Sell Item proceeds and
/// sends amount <c>1</c> LITERALLY — not the 20-unit stack size —
/// matching retail's own literal <c>var_9c = 1</c> send.
/// </summary>
[Fact]
public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack
h.SellItemButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
{
@ -1713,6 +2151,65 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Sells);
}
// ══════════════════════════════════════════════════════════════════════
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// Sell side — retail's <c>RecvNotice_ServerSaysMoveItem</c>
/// (<c>0x004c44a0</c>) silently unstages a staged sell item once it's
/// no longer trackable/owned by the player; this port's closest
/// reachable analogue is the item leaving <c>ClientObjectTable</c>
/// entirely.
/// </summary>
[Fact]
public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.Objects.Remove(PlayerOwnedArmorGuid);
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.SystemMessages); // silent — matches retail's own site
}
/// <summary>
/// Buy side — a staged shop item that drops out of the vendor's
/// CURRENT stock (retired from <c>ClientObjectTable</c> — production's
/// <c>VendorShopItemMaterializer</c> does this on every Refreshed
/// transition that drops a guid; this hand-built harness doesn't mount
/// that class, so the test performs the SAME removal directly) unstages
/// with retail's exact "Removing %s from shopping list" notice
/// (<c>0x004c4246</c>).
/// </summary>
[Fact]
public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = ArmorItemGuid,
Name = "Chainmail",
Type = ItemType.Armor,
ContainerId = VendorGuid,
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.Objects.Remove(ArmorItemGuid);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
// ══════════════════════════════════════════════════════════════════════
@ -1749,9 +2246,11 @@ public sealed class VendorUiControllerTests
Assert.True(h.Dialogs.IsOpen);
Assert.NotNull(h.ShownDialog);
// Exact retail string, read from the decompiled binary's data
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment.
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7
// (Slice 6b/6c review): the raw bytes right after the declared
// string length are UTF-16LE for '?' before the null terminator.
Assert.Equal(
"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?",
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
}

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);
}