acdream/src/AcDream.App/UI/Layout/VendorUiController.cs
Erik 02b735ba4a
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
fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 17:17:04 +02:00

2581 lines
127 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmVendorUI</c>'s "Items" tab
/// (<c>VendorItemsUI</c>) — the browse-only scope of Slice 5.4. LayoutDesc
/// <c>0x21000012</c>, root <c>0x100000B7</c> — discovered and verified
/// against the named-retail decomp in
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §B.3/§B.4.
///
/// <para>
/// <b>Three tabs, not two.</b> The contract (world-interaction-completion.md,
/// Slice 5 decisions) anticipated a "browse/Buy" tab plus a "Sell" tab. The
/// dat authors three DAT-labeled tabs — "Items" (<see cref="ItemsTabId"/>),
/// "Buying" (<see cref="BuyingTabId"/>), "Selling" (<see cref="SellingTabId"/>) —
/// each a state/page pair. Retail's <c>gmVendorUI::OpenVendor</c>
/// (<c>pc:203650</c>) opens tab <see cref="ItemsTabId"/> for the ordinary
/// (mode 2) open this slice always exercises — matching the contract's
/// "browse/Buy tab" language, just under its actual dat name. "Buying"
/// (<c>VendorBuyUI</c>, staged-purchase review) and "Selling"
/// (<c>VendorSellUI</c>, staged-sale review) are Slice 6 territory: their
/// tab buttons render and switch the visible page (so the authored layout
/// looks complete), but neither page is ever populated by this controller —
/// clicking them just shows an empty page. No buy/sell action, no
/// <c>InqAcceptability</c>, no currency mutation happens anywhere in this
/// file.
/// </para>
///
/// <para>
/// <b>Category filter = a dropdown, not more tabs.</b> Retail's
/// <c>VendorItemsUI::AddTypeFilter</c>/<c>::ListContainsType</c>
/// (<c>pc:199667</c>/<c>200132</c>, D0 read in the research doc's §B.4)
/// populate <see cref="UiMenu"/> <see cref="TypeFilterMenuId"/> with one
/// entry per <see cref="CategoryFilters"/> row the vendor's stock actually
/// contains, in retail's fixed table order, and the item list is ALWAYS
/// scoped to exactly one selected category (there is no "show everything"
/// state — an empty selection retail-authentically shows zero items).
/// </para>
///
/// <para>
/// <b>Lifecycle: pure state projection.</b> This controller owns no
/// session/transaction state of its own. It opens/repopulates/closes purely
/// by observing <see cref="VendorState.Changed"/>. The close button (G4,
/// vendor gate finding) does NOT mutate <see cref="VendorState"/> at all —
/// see <see cref="CloseButtonPressed"/> for the retail citation
/// (<c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case,
/// <c>pc:204147-204182</c>): with nothing staged it is a plain window hide,
/// leaving the session open in the background exactly like retail's
/// still-registered range watcher. <see cref="VendorState.Close"/> —
/// client-local per research §A.4, nothing on the wire — is reached only via
/// <see cref="RuntimeVendorRangeQuery.EnforceRange"/>'s distance check, never
/// from a direct field write here.
/// </para>
/// </summary>
public sealed class VendorUiController : IRetainedPanelController, IItemListDragHandler
{
public const uint LayoutId = 0x21000012u;
public const uint RootId = 0x100000B7u;
public const uint CloseId = 0x100000D6u;
public const uint PanelGroupId = 0x100000B8u;
public const uint ItemsTabId = 0x100000B9u;
public const uint BuyingTabId = 0x100000BAu;
public const uint SellingTabId = 0x100000BBu;
public const uint ItemsPageId = 0x100000BCu;
public const uint ItemListId = 0x100000BDu;
public const uint ItemScrollbarId = 0x100000BEu;
public const uint TypeFilterMenuId = 0x100000BFu;
public const uint ItemNameTextId = 0x100000C0u;
public const uint ItemCostTextId = 0x100000C1u;
public const uint BuyButtonId = 0x100000C2u;
public const uint AddButtonId = 0x100000C3u;
// Slice 6 territory — present so the authored layout looks complete,
// never populated here (see the class doc's "three tabs" note).
public const uint BuyingPageId = 0x100000C4u;
public const uint SellingPageId = 0x100000CDu;
// G2 (vendor gate finding): the Buying/Selling pages author their OWN
// item strip + scrollbar pair, geometrically identical to the Items
// tab's (same X/Y/Width/Height, same cell-template attribute 0x1000000e
// -> prototype 0x1000033A, verified against the fixture). Staging
// (populating these lists with a player's held-for-sale / to-buy items)
// is still deferred (Slice 6 territory) — these ids exist ONLY so the
// authored empty-slot fill can be wired, matching the Items list's
// treatment instead of leaving a bare blue background.
public const uint BuyingListId = 0x100000C5u;
public const uint BuyingScrollbarId = 0x100000C6u;
public const uint SellingListId = 0x100000CEu;
public const uint SellingScrollbarId = 0x100000CFu;
// R3 (user-requested retail presentation, closing AP-166's text half):
// the Buying/Selling tabs' own staged-count/total-value and player-
// purse text elements — retail m_buyListText/m_buyPurseText
// (VendorBuyUI::VendorBuyUI, pc:199733-199738) and m_sellListText/
// m_sellPurseText (VendorSellUI::VendorSellUI, pc:199777-199782).
public const uint BuyingListTextId = 0x100000C7u;
public const uint BuyingPurseTextId = 0x100000C8u;
public const uint SellingListTextId = 0x100000D0u;
public const uint SellingPurseTextId = 0x100000D1u;
// Slice 6b: the "Buying" tab's staging-review buttons
// (docs/research/2026-08-08-slice5-vendor-browse-research.md §B.4 D0
// tree). All four are optional (nullable) the same way BuyButtonId/
// AddButtonId are — a missing authored control degrades gracefully
// instead of failing Bind.
public const uint BuyItemButtonId = 0x100000C9u;
public const uint BuyAllButtonId = 0x100000CAu;
public const uint BuyClearItemButtonId = 0x100000CBu;
public const uint BuyClearListButtonId = 0x100000CCu;
// Slice 6c: the "Selling" tab's staging-review buttons — same D0 tree.
public const uint SellItemButtonId = 0x100000D2u;
public const uint SellAllButtonId = 0x100000D3u;
public const uint SellClearItemButtonId = 0x100000D4u;
public const uint SellClearListButtonId = 0x100000D5u;
/// <summary>
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
/// Retail <c>UIElement_Menu::MakePopup</c> (<c>0x0046D310</c>,
/// <c>pc:120705-120764</c>) reads a PER-MENU popup <c>LayoutDesc</c>
/// from element attribute 7 (DataID) and a root element id from
/// attribute 6 (Enum); <c>Initialize</c> (<c>0x0046D440</c>,
/// <c>pc:120789-120828</c>) then locates the popup's
/// <c>UIElement_ListBox</c> child via attribute 2. Element
/// <c>0x100000BF</c>'s resolved attributes — verified against the
/// committed fixture (<c>vendor_21000012_100000B7.json</c>, property
/// state <c>4294967295</c>) AND a live-dat scan of LayoutDesc
/// <c>0x21000043</c> — are <c>7=0x21000043</c> (popup catalog layout),
/// <c>6=0x1000034F</c> (popup root), <c>2=0x10000350</c> (its
/// <c>ListBox</c>), <c>9=0x10000352</c> (the row-template element the
/// dropdown's rows inherit from). NOTE: the original review's citation
/// of <c>6=0x1000014F</c> was a transcription slip — the fixture and a
/// live-dat re-scan both read <c>0x1000034F</c>.
/// <para>
/// <see cref="UiMenu"/> draws its OWN simplified column-major grid, not
/// retail's nested <c>ListBox</c> tree, so this port reads off the
/// AUTHORED VALUES instead of instantiating that subtree: row size
/// 100x18 (<c>0x10000352</c>'s own Width/Height), 6 rows per column
/// (the <c>ListBox 0x10000350</c>'s own Height 108 / row height 18),
/// and the row sprites <c>0x060012B3</c> (Normal)/<c>0x060012B4</c>
/// (Highlight) — INSTEAD of reusing <see cref="UiMenu"/>'s class-default
/// constants, which are chat's own authored values (191x17,
/// RowsPerColumn 7, sprites 0x0600124C/4D/4E: chat's channel menu
/// (element <c>0x10000014</c> in LayoutDesc <c>0x21000006</c>) has the
/// SAME three attributes pointing at a completely different, bespoke
/// popup subtree it authors within its own layout —
/// <c>7=0x21000006</c> (its own layout id), <c>6=0x1000001C</c>,
/// <c>9=0x1000001E</c>. This keeps <see cref="ChatWindowController"/>
/// byte-identical (it is not touched by this fix) while giving
/// vendor's dropdown its own authored geometry instead of chat's.
/// </para>
/// <para>
/// The popup root <c>0x1000034F</c> carries NO direct-state sprite of
/// its own (unlike chat's <c>0x0600124C</c> panel fill) — each row
/// tiles its own background with no gap between rows (6 rows x 18px =
/// the ListBox's exact 108px height), so
/// <see cref="UiMenu.PopupBgSprite"/> is left at its default 0
/// (<see cref="UiMenu"/>'s sprite draw helper no-ops on id 0), matching
/// the absent authored sprite exactly rather than inventing one.
/// </para>
/// <para>
/// <b>G5 correction (vendor gate finding): it is a SCROLLABLE single
/// column, not a 3-column grid.</b> The F1 review's "column-major grid"
/// framing was wrong — a live-dat scan (<c>tools/VendorLayoutScan</c>,
/// <c>dump</c>/<c>resolved 0x21000043 0x1000034F</c>) shows
/// <c>0x1000034F</c> has TWO children, not one: the ListBox
/// <c>0x10000350</c> (100x108, resolved attribute <c>0x5E=6</c>/
/// <c>0x5F=1</c> — six rows, ONE column) AND a SIBLING
/// <c>UIElement_Scrollbar</c> (class <c>0xB</c>, element
/// <c>0x10000351</c>, 16x108, docked at X=100 immediately right of the
/// list, with a real thumb/up/down-button subtree matching
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
/// <c>0x10000071</c>) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>, down button
/// (element <c>0x10000072</c>) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>,
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
/// visible rows, retail's actual rendering is a single scrolling column
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
/// highlight — not our earlier 3-column x 6-row grid showing all 18 at
/// once). <see cref="UiMenu.Scrollable"/> switches the popup to this
/// shape; <see cref="UiMenu.RowsPerColumn"/> keeps its existing meaning
/// as the authored visible-row count (still 6 — 108px ListBox height /
/// 18px row height, now interpreted as "rows before scrolling" instead
/// of "rows before wrapping to a new column"). Chat's own popup
/// (LayoutDesc <c>0x21000006</c>, element <c>0x1000001C</c>) has NO
/// sibling scrollbar element and is unaffected —
/// <see cref="ChatWindowController"/> never sets <c>Scrollable</c>, so
/// it keeps the original grid path byte-identical.
/// </para>
/// <para>
/// The button FACE reuses the same two sprites: <c>0x060012B3</c> is
/// literally what vendor's OWN button-face child (<c>0x1000034D</c>)
/// resolves to in the fixture, and <c>0x060012B4</c> (the row
/// template's "Highlight" state) is the paired open/pressed look. This
/// remains a deliberate, bounded choice — <see cref="UiMenu"/>'s
/// single-texture 3-slice <c>DrawButtonFace</c> (end caps sized for
/// chat's 46px LED-arrow texture) is not redesigned into a two-piece
/// label+arrow renderer for this plain gold body.
/// </para>
/// <para>
/// <b>G6 (vendor gate finding, item 1 — the missing green arrow
/// indicator): closes the arrow-cap half of AP-161's residual.</b>
/// Retail's actual closed-button chrome IS a separate two-piece
/// label+arrow assembly — label <c>0x1000034D</c> (100x18, HJustify
/// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
/// <c>0x1000034E</c> docked at the button's right edge (X=100,Y=0
/// within the 117-wide button), with its own Normal/Highlight states
/// resolving to <c>0x060012B1</c> (closed)/<c>0x060012B2</c> (open) —
/// verified live via <c>tools/VendorLayoutScan</c>'s <c>resolved</c>
/// command reading the element's two StateMedia images directly out of
/// <c>client_local_English.dat</c>. <see cref="UiMenu.ArrowCapClosedSprite"/>/
/// <see cref="UiMenu.ArrowCapOpenSprite"/> draw this AS AN OVERLAY on top
/// of the existing 3-slice body (additive, not a DrawButtonFace rewrite)
/// so the green arrow now flips down (closed)/up (open) exactly like
/// retail, without touching chat's face (whose arrow is baked into its
/// own texture and never sets these).
/// </para>
/// <para>
/// <b>G7 (vendor gate finding, item 2 — the popup opened upward instead
/// of downward).</b> Retail's <c>UIElement_Menu::Open</c>
/// (<c>pc:120210-120252</c>, <c>0x0046cc30</c>) places the popup at
/// <c>ScreenY1(button)</c> (the button's own BOTTOM edge — i.e. BELOW
/// it) unless the menu authors bool attribute 5 true, in which case it
/// places it at <c>ScreenY0(button) - popupHeight</c> (ABOVE).
/// <c>UIElement::GetAttribute_Bool</c> (<c>pc:106749-106778</c>) defaults
/// an ABSENT attribute to false. Element <c>0x100000BF</c>'s resolved
/// attribute bag (the same fixture cited throughout this doc) carries NO
/// property "5" at all — unlike chat's channel menu (<c>0x10000014</c> in
/// LayoutDesc <c>0x21000006</c>), which explicitly authors property "5" =
/// <c>true</c>. So retail opens vendor's dropdown DOWNWARD and chat's
/// UPWARD — two different authored choices, not one hardcoded direction.
/// <see cref="UiMenu.OpenUpward"/> (default <c>true</c>, preserving
/// chat's untouched behavior) is set <c>false</c> here to match.
/// Retail's <c>Open</c> has no dynamic screen-edge clamp or flip of its
/// own — the direction is the fixed authored attribute, full stop — so
/// none is added here either; see <see cref="UiMenu.OpenUpward"/>'s own
/// doc for the citation.
/// </para>
/// <para>
/// <b>G8 (vendor gate finding, item 3 — popup/button text overflowing
/// instead of sitting flush left).</b> <see cref="UiMenu.TextIndent"/>
/// (19px) and <see cref="UiMenu.ButtonTextIndent"/> (20px) are CHAT's
/// own authored offsets — clearing space for a baked-in row checkbox and
/// a button-face LED socket, respectively. Vendor's row template
/// (<c>0x10000352</c>, live-dat verified <c>HJustify=Left</c>) has no
/// checkbox child at all, and vendor's button label child
/// (<c>0x1000034D</c>) is itself <c>HJustify=Left</c> at X=0 with no LED
/// art — so both indents are set to 0 here, retail's own left-justified
/// <c>UiText</c> convention for an icon-less label (every OTHER
/// dat-driven <c>UiText</c> in this codebase sets <c>Padding=0f</c>).
/// Reusing chat's 19px row indent measurably overflowed the authored
/// 100px-wide row: the longest category label, "Spell Components",
/// measures 92px at the default retail font (<c>Font 0x40000000</c>,
/// live-measured via <c>tools/VendorLayoutScan measure</c>) —
/// 19+92=111px &gt; the 100px row, an 11px overflow; with 0px it fits
/// with 8px to spare.
/// </para>
/// </summary>
private const int TypeMenuRowsPerColumn = 6;
private const float TypeMenuRowHeight = 18f;
private const float TypeMenuColumnWidth = 100f;
private const uint TypeMenuItemNormalSprite = 0x060012B3u;
private const uint TypeMenuItemHighlightSprite = 0x060012B4u;
private const uint TypeMenuNormalSprite = 0x060012B3u;
private const uint TypeMenuPressedSprite = 0x060012B4u;
// G6: the separate arrow-cap overlay (element 0x1000034E) — see the
// class doc's "G6" paragraph above.
private const uint TypeMenuArrowCapClosedSprite = 0x060012B1u;
private const uint TypeMenuArrowCapOpenSprite = 0x060012B2u;
// G5 (vendor gate finding): the popup's docked scrollbar (element
// 0x10000351, verified via tools/VendorLayoutScan against the live dat —
// see the class doc's "G5 correction" paragraph above). Width/button
// extent both 16px matching the authored element/child sizes exactly.
private const float TypeMenuScrollbarWidth = 16f;
private const float TypeMenuScrollButtonExtent = 16f;
private const uint TypeMenuScrollTrackSprite = 0x06004C5Fu;
private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u;
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
private const uint TypeMenuScrollUpSprite = 0x06004C69u;
private const uint TypeMenuScrollDownSprite = 0x06004C6Cu;
/// <summary>
/// Retail's ordered category table, transcribed verbatim from
/// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain
/// (<c>pc:200792-201005</c>). Order matters: it's the display/insertion
/// order AND the "first present entry wins on fresh open" default. Every
/// mask cross-checked bit-for-bit against
/// <see cref="ItemType"/> — see the research doc's §B.4 table.
/// </summary>
private static readonly (string Label, ItemType Mask)[] CategoryFilters =
[
("Armor", ItemType.Armor), // 0x2
("Books, Paper", ItemType.Writable), // 0x2000
("Clothing", ItemType.Clothing), // 0x4
("Containers", ItemType.Container), // 0x200
("Food", ItemType.Food), // 0x20
("Gems", ItemType.Gem), // 0x800
("Jewelry", ItemType.Jewelry), // 0x8
("Keys, Tools", ItemType.TinkeringTool | ItemType.Key), // 0x20004000
("Miscellaneous", ItemType.Useless | ItemType.Misc | ItemType.Creature), // 0x490
("Services", ItemType.Service), // 0x100000
("Spell Components", ItemType.SpellComponents), // 0x1000
("Trade Notes", ItemType.PromissoryNote), // 0x40000
("Weapons", ItemType.Weapon), // 0x101
("Mana Stones", ItemType.ManaStone), // 0x80000
("Magic Items", ItemType.Caster), // 0x8000
("Alchemical Items", ItemType.CraftAlchemyIntermediate | ItemType.CraftAlchemyBase), // 0x4800000
("Cooking Items", ItemType.CraftCookingBase), // 0x400000
("Fletching Items", ItemType.CraftFletchingIntermediate | ItemType.CraftFletchingBase), // 0x9000000
];
private readonly VendorState _vendor;
private readonly RetailWindowHandle _window;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid;
private readonly ItemInteractionController _itemInteraction;
private readonly SelectionState _selection;
private readonly StackSplitQuantityState _splitQuantity;
private readonly UiElement _itemsPage;
private readonly UiElement _buyingPage;
private readonly UiElement _sellingPage;
private readonly UiElement _itemsTab;
private readonly UiElement _buyingTab;
private readonly UiElement _sellingTab;
private readonly UiItemList _itemList;
// G2: presentation-only strips (empty-slot fill only, never populated —
// see the BuyingListId/SellingListId doc comments).
private readonly UiItemList? _buyingList;
private readonly UiItemList? _sellingList;
private readonly UiMenu _typeMenu;
private readonly UiText _itemNameText;
private readonly UiText _itemCostText;
// R3: the Buying/Selling tabs' own staged summary text — optional, the
// same nullable degrade-gracefully convention as the staging buttons.
private readonly UiText? _buyListText;
private readonly UiText? _buyPurseText;
private readonly UiText? _sellListText;
private readonly UiText? _sellPurseText;
private readonly UiButton? _close;
private readonly UiButton? _buyButton;
private readonly UiButton? _addButton;
// Slice 6b: "Buying" tab staging-review buttons.
private readonly UiButton? _buyItemButton;
private readonly UiButton? _buyAllButton;
private readonly UiButton? _buyClearItemButton;
private readonly UiButton? _buyClearListButton;
// Slice 6c: "Selling" tab staging-review buttons.
private readonly UiButton? _sellItemButton;
private readonly UiButton? _sellAllButton;
private readonly UiButton? _sellClearItemButton;
private readonly UiButton? _sellClearListButton;
// Slice 6b/6c: retail's m_buyList/m_sellList — see VendorStagingList's doc comment.
private readonly VendorStagingList _buyStaging = new();
private readonly VendorStagingList _sellStaging = new();
private readonly RetailDialogFactory? _dialogs;
private readonly Action<string>? _systemMessage;
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
private int _selectedCategoryIndex = -1;
// Slice 6.3/6b: tracks "does the CURRENT selection permit Buy/Add"
// separately from "is a Buy request currently in flight"
// (RecomputeBuyButtonEnabled combines both for the Buy AND Add buttons —
// Add to List is now wired (Slice 6b), no longer permanently disabled).
private bool _buyEnabledBySelection;
// Slice 6b: gmVendorUI::m_curDialogContext — a nonzero value means the
// 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(
VendorState vendor,
RetailWindowHandle window,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
ClientObjectTable objects,
Func<uint> playerGuid,
ItemInteractionController itemInteraction,
SelectionState selection,
StackSplitQuantityState splitQuantity,
UiElement itemsPage,
UiElement buyingPage,
UiElement sellingPage,
UiElement itemsTab,
UiElement buyingTab,
UiElement sellingTab,
UiItemList itemList,
UiScrollbar? itemScrollbar,
UiItemList? buyingList,
UiScrollbar? buyingScrollbar,
UiItemList? sellingList,
UiScrollbar? sellingScrollbar,
UiMenu typeMenu,
UiText itemNameText,
UiText itemCostText,
UiText? buyListText,
UiText? buyPurseText,
UiText? sellListText,
UiText? sellPurseText,
UiButton? close,
UiButton? buyButton,
UiButton? addButton,
UiButton? buyItemButton,
UiButton? buyAllButton,
UiButton? buyClearItemButton,
UiButton? buyClearListButton,
UiButton? sellItemButton,
UiButton? sellAllButton,
UiButton? sellClearItemButton,
UiButton? sellClearListButton,
RetailDialogFactory? dialogs,
Action<string>? systemMessage,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolveSprite,
uint emptySlotSprite,
uint buyingEmptySlotSprite,
uint sellingEmptySlotSprite)
{
_vendor = vendor;
_window = window;
_resolveIcon = resolveIcon;
_objects = objects;
_playerGuid = playerGuid;
_itemInteraction = itemInteraction;
_selection = selection;
_splitQuantity = splitQuantity;
_itemsPage = itemsPage;
_buyingPage = buyingPage;
_sellingPage = sellingPage;
_itemsTab = itemsTab;
_buyingTab = buyingTab;
_sellingTab = sellingTab;
_itemList = itemList;
_buyingList = buyingList;
_sellingList = sellingList;
_typeMenu = typeMenu;
_itemNameText = itemNameText;
_itemCostText = itemCostText;
_buyListText = buyListText;
_buyPurseText = buyPurseText;
_sellListText = sellListText;
_sellPurseText = sellPurseText;
_close = close;
_buyButton = buyButton;
_addButton = addButton;
_buyItemButton = buyItemButton;
_buyAllButton = buyAllButton;
_buyClearItemButton = buyClearItemButton;
_buyClearListButton = buyClearListButton;
_sellItemButton = sellItemButton;
_sellAllButton = sellAllButton;
_sellClearItemButton = sellClearItemButton;
_sellClearListButton = sellClearListButton;
_dialogs = dialogs;
_systemMessage = systemMessage;
_itemList.Columns = 1;
_itemList.SingleRow = true;
_itemList.HorizontalScroll = true;
_itemList.CellWidth = 32f;
_itemList.CellHeight = 32f;
// F7b (Slice 5.4 review): fill visible empty slots the same way
// ExternalContainerController.ConfigureList does — an authored,
// mostly-empty single-row strip should show its empty-slot
// background art in the unused cells, not blank space.
_itemList.FillVisibleEmptySlots = true;
if (emptySlotSprite != 0u)
_itemList.CellEmptySprite = emptySlotSprite;
// F3 (Slice 6 review): vendor rows are never drag sources — see
// UiItemSlot.AllowDragSource. Set here too (empty cells are already
// non-sources via ItemId==0) so the invariant holds by construction
// rather than incidentally.
_itemList.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = _itemList.SpriteResolve,
AllowDragSource = false,
};
// Slice 6.1: mirrors ExternalContainerController's own
// right-click-examine wiring (UiItemSlot.OnEvent's RightClick case).
// Now that shop items are materialized into ClientObjectTable (see
// VendorShopItemMaterializer), AppraisalUiController.Apply's lookup
// succeeds and this stops being a dead end — closes half of AP-161
// finding #2.
_itemList.ExamineItemRequested = ExamineItem;
if (itemScrollbar is not null)
{
itemScrollbar.Model = _itemList.Scroll;
itemScrollbar.Horizontal = true;
}
// G2 (vendor gate finding): Buying/Selling get the SAME empty-slot
// fill treatment as the Items strip above — presentation only, the
// lists are never populated (staging into these tabs stays Slice 6
// territory, unimplemented). Mounting here (rather than skipping
// entirely) is what replaces the bare blue authored background with
// the correct empty-cell art the instant the panel opens.
ConfigureEmptyStrip(_buyingList, buyingEmptySlotSprite);
if (buyingScrollbar is not null && _buyingList is not null)
{
buyingScrollbar.Model = _buyingList.Scroll;
buyingScrollbar.Horizontal = true;
}
ConfigureEmptyStrip(_sellingList, sellingEmptySlotSprite);
if (sellingScrollbar is not null && _sellingList is not null)
{
sellingScrollbar.Model = _sellingList.Scroll;
sellingScrollbar.Horizontal = true;
}
// Slice 6c: gmVendorUI::HandleDropRelease routes EVERY drop in the
// whole panel through one IsAncestorOfMe(target, m_sellShopList)
// gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
// 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
// instead of UiMenu's chat-shaped class defaults.
_typeMenu.SpriteResolve = resolveSprite;
_typeMenu.DatFont = datFont;
_typeMenu.Font = debugFont;
_typeMenu.NormalSprite = TypeMenuNormalSprite;
_typeMenu.PressedSprite = TypeMenuPressedSprite;
_typeMenu.ItemNormalSprite = TypeMenuItemNormalSprite;
_typeMenu.ItemHighlightSprite = TypeMenuItemHighlightSprite;
_typeMenu.RowsPerColumn = TypeMenuRowsPerColumn;
_typeMenu.RowHeight = TypeMenuRowHeight;
_typeMenu.ColumnWidth = TypeMenuColumnWidth;
// G5: the authored popup is a scrollable single column with a docked
// scrollbar, not a column-major grid — see the class doc's "G5
// correction" paragraph above.
_typeMenu.Scrollable = true;
_typeMenu.ScrollbarWidth = TypeMenuScrollbarWidth;
_typeMenu.ScrollButtonExtent = TypeMenuScrollButtonExtent;
_typeMenu.ScrollTrackSprite = TypeMenuScrollTrackSprite;
_typeMenu.ScrollThumbTopSprite = TypeMenuScrollThumbTopSprite;
_typeMenu.ScrollThumbSprite = TypeMenuScrollThumbSprite;
_typeMenu.ScrollThumbBottomSprite = TypeMenuScrollThumbBottomSprite;
_typeMenu.ScrollUpSprite = TypeMenuScrollUpSprite;
_typeMenu.ScrollDownSprite = TypeMenuScrollDownSprite;
// G6: the separate open/closed arrow-cap overlay — see the class
// doc's "G6" paragraph above.
_typeMenu.ArrowCapClosedSprite = TypeMenuArrowCapClosedSprite;
_typeMenu.ArrowCapOpenSprite = TypeMenuArrowCapOpenSprite;
// G7: retail authors NO bool attribute 5 on this menu, so
// UIElement_Menu::Open's absent-defaults-false path opens the popup
// DOWNWARD (ScreenY1, the button's own bottom edge) — see the class
// doc's "G7" paragraph above. Chat's own menu is unaffected (its
// controller never touches this property, leaving UiMenu's true
// default).
_typeMenu.OpenUpward = false;
// G8: vendor's row/button-label children carry no checkbox/LED art
// to clear (unlike chat's), so both indents are flush-left (0) —
// see the class doc's "G8" paragraph above.
_typeMenu.TextIndent = 0f;
_typeMenu.ButtonTextIndent = 0f;
_typeMenu.OnSelect = payload =>
{
if (payload is uint mask) SelectCategory(mask);
};
_typeMenu.ButtonLabelProvider = () =>
_selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? _presentCategories[_selectedCategoryIndex].Label
: string.Empty;
RetailTabBinding.SetClick(_itemsTab, () => ShowTab(VendorPanelTab.Items));
RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying));
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
if (_close is not null)
_close.OnClick = CloseButtonPressed;
// Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case —
// BuySingleItem(selectedID) — an immediate single-item purchase, no
// staging list required (research doc §B.1).
if (_buyButton is not null)
_buyButton.OnClick = BuySelectedItem;
// Slice 6b: 0x100000c3 — VendorItemsUI::AddToBuyList (research doc
// §Q3). No longer permanently disabled (AP-161 F8 residual closes).
if (_addButton is not null)
_addButton.OnClick = AddSelectedToBuyList;
// Slice 6b: the "Buying" tab's four staging buttons.
if (_buyItemButton is not null)
_buyItemButton.OnClick = BuyItemButtonPressed;
if (_buyAllButton is not null)
_buyAllButton.OnClick = BuyAllButtonPressed;
if (_buyClearItemButton is not null)
_buyClearItemButton.OnClick = BuyClearItemButtonPressed;
if (_buyClearListButton is not null)
_buyClearListButton.OnClick = () => _buyStaging.Clear();
// Slice 6c: the "Selling" tab's four staging buttons.
if (_sellItemButton is not null)
_sellItemButton.OnClick = SellItemButtonPressed;
if (_sellAllButton is not null)
_sellAllButton.OnClick = SellAllButtonPressed;
if (_sellClearItemButton is not null)
_sellClearItemButton.OnClick = SellClearItemButtonPressed;
if (_sellClearListButton is not null)
_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;
// R3: the tabs' own staged-count/total-value/purse text must track
// every staging change, matching retail's Update() -> ...
// UpdateTransactionValue()/UpdateTotalValue() chain (VendorBuyUI::Update
// pc:202996-203005, VendorSellUI::Update pc:203009-203018) — both
// called unconditionally on EVERY staging mutation, not just Add/Remove.
_buyStaging.Changed += UpdateBuyTransactionText;
_sellStaging.Changed += UpdateSellTransactionText;
// R3: a player money change (a purchase/sale elsewhere, a pickup, a
// drop) must repaint the purse line even with staging unchanged —
// retail's own m_totalValue/m_last_sale-driven purse text has no
// separate "staging changed" gate from "holdings changed" (both
// UpdateTotalValue calls read the LIVE holding fresh, same as
// BuildCostText's own PropertyInt.CoinValue read).
_objects.ObjectUpdated += OnObjectMoneyChanged;
ShowTab(VendorPanelTab.Items);
ClearContent();
_vendor.Changed += OnVendorChanged;
// Slice 6.2: SelectionState is now the AUTHORITY (research doc §B.4:
// vendor-context selections flow through the SAME global
// ACCWeenieObject::SetSelectedObject primitive as every other
// origin) — this panel is a CONSUMER, mirroring
// ExternalContainerController.OnSelectionChanged's shape exactly.
_selection.Changed += OnSelectionTransition;
// Slice 6.2: mirrors ExternalContainerController.OnObjectRemoved —
// retail's VendorItemsUI::RemoveFromShop (pc:202848-202850,
// 0x004c3d4a) clears the global selection when a shop item leaves
// the list (there is no separate CloseVendor-level SetSelectedObject(0)
// call; retail's ItemList_Flush on close does not touch selection
// directly). Since VendorShopItemMaterializer removes every
// materialized item from ClientObjectTable on Close/Reset/replace
// (Slice 6.1), subscribing here gives "vendor session close clears
// a vendor-owned selection" as a consequence of the SAME generic
// mechanism every other panel already uses, not a vendor-specific
// special case.
_objects.ObjectRemoved += OnObjectRemoved;
// Slice 6.3: mirrors ExternalContainerController's own
// _itemInteraction.StateChanged subscription — the Buy button must
// disable the instant a reservation is taken (BeginUseRequestReservation
// increments BusyCount synchronously, before the wire send), and
// re-enable on the matching UseDone/cancel, without polling.
_itemInteraction.StateChanged += OnInteractionStateChanged;
// F2 (Slice 6 review): retail re-runs UpdateItemsUI on every slider
// change (gmVendorUI::RecvNotice_StackSliderChanged, pc:203262-203278)
// — the displayed name/price must track the LIVE split, not just the
// value at selection time.
_splitQuantity.Changed += OnSplitQuantityChanged;
}
/// <param name="objects">
/// F2/F3 (Slice 5.4 review): the local player's coin total
/// (<c>PropertyInt.CoinValue</c>) lives here — the same generic
/// <c>ClientObjectTable</c> int-property bundle every other
/// PropertyInt-driven display reads, populated by
/// <c>ObjectTableWiring</c>'s <c>PrivateUpdatePropertyInt</c> routing.
/// </param>
/// <param name="playerGuid">Resolves the local player's guid to look up in <paramref name="objects"/>.</param>
/// <param name="itemInteraction">
/// Slice 6.1: the shared retail item interaction orchestrator — its
/// <c>ExamineSelectedOrEnterMode</c> is what right-click-examine on a
/// shop row routes through, mirroring
/// <see cref="ExternalContainerController"/>'s own examine wiring.
/// </param>
/// <param name="selection">
/// Slice 6.2: the canonical <see cref="SelectionState"/> — now the
/// AUTHORITY for shop-row selection (row clicks, the F4 auto-select
/// fallback, and right-click examine all call
/// <see cref="SelectionState.Select"/> directly); this panel only
/// listens and reacts, the same way every sibling panel does.
/// </param>
/// <param name="splitQuantity">
/// Slice 6.3: the shared toolbar stack-quantity control (retail
/// <c>GenItemHolder::splitSize</c>/<c>maxSplitSize</c>) — the Buy button
/// reads the SAME live value <see cref="SelectedObjectController"/>
/// seeds/the player adjusts via <c>ItemHolder::GetObjectSplitSize</c>
/// (<c>0x00586F00</c>), matching retail's <c>BuySingleItem</c>
/// (<c>pc:201674-201681</c>: quantity 1 if <c>_stackSize &lt;= 1</c>,
/// else the current slider value).
/// </param>
/// <param name="datFont">Retail dat font for the category dropdown's button/row labels.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param>
/// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param>
/// <param name="emptySlotSprite">Authored empty-slot background for the item strip, or 0 for none.</param>
/// <param name="buyingEmptySlotSprite">
/// G2: authored empty-slot background for the Buying tab's own item
/// strip (<see cref="BuyingListId"/>), or 0 for none.
/// </param>
/// <param name="sellingEmptySlotSprite">
/// G2: authored empty-slot background for the Selling tab's own item
/// strip (<see cref="SellingListId"/>), or 0 for none.
/// </param>
public static VendorUiController? Bind(
ImportedLayout layout,
VendorState vendor,
RetailWindowHandle window,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
ClientObjectTable objects,
Func<uint> playerGuid,
ItemInteractionController itemInteraction,
SelectionState selection,
StackSplitQuantityState splitQuantity,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolveSprite,
uint emptySlotSprite = 0u,
uint buyingEmptySlotSprite = 0u,
uint sellingEmptySlotSprite = 0u,
// Slice 6b: the panel's own X-close confirmation when staging is
// 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, 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);
ArgumentNullException.ThrowIfNull(vendor);
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(resolveIcon);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(playerGuid);
ArgumentNullException.ThrowIfNull(itemInteraction);
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(splitQuantity);
ArgumentNullException.ThrowIfNull(resolveSprite);
if (layout.FindElement(ItemsPageId) is not { } itemsPage
|| layout.FindElement(BuyingPageId) is not { } buyingPage
|| layout.FindElement(SellingPageId) is not { } sellingPage
|| layout.FindElement(ItemsTabId) is not { } itemsTab
|| layout.FindElement(BuyingTabId) is not { } buyingTab
|| layout.FindElement(SellingTabId) is not { } sellingTab
|| layout.FindElement(ItemListId) is not UiItemList itemList
|| layout.FindElement(TypeFilterMenuId) is not UiMenu typeMenu
|| layout.FindElement(ItemNameTextId) is not UiText itemNameText
|| layout.FindElement(ItemCostTextId) is not UiText itemCostText)
{
return null;
}
// R3: the Buying/Selling tabs' own summary text — optional, same
// degrade-gracefully convention as the staging buttons.
UiText? buyListText = layout.FindElement(BuyingListTextId) as UiText;
UiText? buyPurseText = layout.FindElement(BuyingPurseTextId) as UiText;
UiText? sellListText = layout.FindElement(SellingListTextId) as UiText;
UiText? sellPurseText = layout.FindElement(SellingPurseTextId) as UiText;
UiButton? close = layout.FindElement(CloseId) as UiButton;
UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar;
UiButton? buyButton = layout.FindElement(BuyButtonId) as UiButton;
UiButton? addButton = layout.FindElement(AddButtonId) as UiButton;
// G2: optional — presentation-only strips, absent gracefully no-ops
// (see the class-level BuyingListId/SellingListId doc comments).
UiItemList? buyingList = layout.FindElement(BuyingListId) as UiItemList;
UiScrollbar? buyingScrollbar = layout.FindElement(BuyingScrollbarId) as UiScrollbar;
UiItemList? sellingList = layout.FindElement(SellingListId) as UiItemList;
UiScrollbar? sellingScrollbar = layout.FindElement(SellingScrollbarId) as UiScrollbar;
// Slice 6b/6c: the eight staging-review buttons — all optional.
UiButton? buyItemButton = layout.FindElement(BuyItemButtonId) as UiButton;
UiButton? buyAllButton = layout.FindElement(BuyAllButtonId) as UiButton;
UiButton? buyClearItemButton = layout.FindElement(BuyClearItemButtonId) as UiButton;
UiButton? buyClearListButton = layout.FindElement(BuyClearListButtonId) as UiButton;
UiButton? sellItemButton = layout.FindElement(SellItemButtonId) as UiButton;
UiButton? sellAllButton = layout.FindElement(SellAllButtonId) as UiButton;
UiButton? sellClearItemButton = layout.FindElement(SellClearItemButtonId) as UiButton;
UiButton? sellClearListButton = layout.FindElement(SellClearListButtonId) as UiButton;
return new VendorUiController(
vendor,
window,
resolveIcon,
objects,
playerGuid,
itemInteraction,
selection,
splitQuantity,
itemsPage,
buyingPage,
sellingPage,
itemsTab,
buyingTab,
sellingTab,
itemList,
itemScrollbar,
buyingList,
buyingScrollbar,
sellingList,
sellingScrollbar,
typeMenu,
itemNameText,
itemCostText,
buyListText,
buyPurseText,
sellListText,
sellPurseText,
close,
buyButton,
addButton,
buyItemButton,
buyAllButton,
buyClearItemButton,
buyClearListButton,
sellItemButton,
sellAllButton,
sellClearItemButton,
sellClearListButton,
dialogs,
systemMessage,
datFont,
debugFont,
resolveSprite,
emptySlotSprite,
buyingEmptySlotSprite,
sellingEmptySlotSprite);
}
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;
_buyingPage.Visible = tab == VendorPanelTab.Buying;
_sellingPage.Visible = tab == VendorPanelTab.Selling;
RetailTabBinding.SetOpen(_itemsTab, tab == VendorPanelTab.Items);
RetailTabBinding.SetOpen(_buyingTab, tab == VendorPanelTab.Buying);
RetailTabBinding.SetOpen(_sellingTab, tab == VendorPanelTab.Selling);
}
private void OnVendorChanged(VendorTransition transition)
{
switch (transition.Kind)
{
case VendorStateTransitionKind.Opened:
// F6 (Slice 5.4 review): a DIFFERENT vendor than whatever was
// previously open — gmVendorUI::OpenVendor flushes sub-UIs
// when sameVendor==0 (pc:203664-203667), so the previous
// vendor's category selection must not carry over into the
// new vendor's (unrelated) category list. VendorTransition
// already distinguishes this case from same-vendor
// Refreshed (VendorState.Apply's sameVendor check), so
// resetting here is sufficient — RebuildCategories' existing
// clamp (selected<0 -> 0) then lands on the new vendor's
// first present category, matching retail.
// Slice 6b/6c: a different vendor's staged items are for a
// shop the player is no longer looking at — clear both
// staging lists the same way the category selection resets.
_buyStaging.Clear();
_sellStaging.Clear();
_selectedCategoryIndex = -1;
ShowTab(VendorPanelTab.Items);
RebuildCategories();
_window.Show();
break;
case VendorStateTransitionKind.Refreshed:
// Same vendor re-approached (post-buy/sell refresh, Slice 6)
// — preserve the selection via RebuildCategories' clamp,
// matching retail's sameVendor==1 path. Staging is NOT
// cleared here: a Refreshed transition follows a Buy All/
// Sell All send, which already flushed its own list
// synchronously at send time (retail: PackableList::Flush
// right after SendShopEvent, pc:204076/label_4c560a) — by
// the time this fires the relevant list is already empty in
// the normal flow, and the OTHER (untouched) list must
// survive a refresh triggered by its sibling.
ShowTab(VendorPanelTab.Items);
RebuildCategories();
_window.Show();
break;
case VendorStateTransitionKind.Closed:
case VendorStateTransitionKind.Reset:
// Slice 6b/6c: session close/teardown clears staging WITH
// the session (contract's C2/C3 close semantics).
_buyStaging.Clear();
_sellStaging.Clear();
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;
}
}
/// <summary>
/// Port of the tail of <c>VendorItemsUI::OpenVendor</c>
/// (<c>pc:200789-201022</c>): flush the menu, walk
/// <see cref="CategoryFilters"/> in order keeping only entries
/// <c>ListContainsType</c> would keep, then re-derive the selected index
/// with retail's exact clamp (preserve if still valid, else clamp to the
/// last entry, else 0).
/// </summary>
private void RebuildCategories()
{
IReadOnlyList<VendorShopItem> items = _vendor.Items;
_presentCategories.Clear();
foreach ((string label, ItemType mask) in CategoryFilters)
{
uint maskValue = (uint)mask;
bool present = false;
for (int i = 0; i < items.Count; i++)
{
if (((items[i].ItemType ?? 0u) & maskValue) != 0u)
{
present = true;
break;
}
}
if (present) _presentCategories.Add((label, mask));
}
int selected = _selectedCategoryIndex;
if (selected >= _presentCategories.Count - 1)
selected = _presentCategories.Count - 1;
if (selected < 0)
selected = 0;
_selectedCategoryIndex = selected;
_typeMenu.Items = _presentCategories
.Select(entry => new UiMenu.MenuItem(entry.Label, (object)(uint)entry.Mask))
.ToArray();
_typeMenu.Selected = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? (object)(uint)_presentCategories[_selectedCategoryIndex].Mask
: null;
RebuildItemList();
}
/// <summary>
/// User picked a different dropdown entry — port of
/// <c>VendorItemsUI::UpdateItemsList</c>'s explicit-mask branch
/// (<c>arg2 != 0</c>, <c>pc:201039-201040</c>).
/// </summary>
private void SelectCategory(uint mask)
{
int index = _presentCategories.FindIndex(entry => (uint)entry.Mask == mask);
if (index < 0 || index == _selectedCategoryIndex) return;
_selectedCategoryIndex = index;
_typeMenu.Selected = (object)mask;
RebuildItemList();
}
/// <summary>
/// Port of <c>VendorItemsUI::UpdateItemsList</c>'s item-insertion loop
/// (<c>pc:201059-201158</c>): only items whose type intersects the
/// active mask are shown. There is no "all categories" state — an empty
/// <see cref="_presentCategories"/> (no vendor stock at all, or nothing
/// selected) shows zero rows, matching retail.
/// </summary>
/// <remarks>
/// F4/F7a (Slice 5.4 review) + F6 (Slice 6 review): the tail of the SAME
/// retail function (<c>pc:201180-201190</c>) — after every rebuild the
/// list unconditionally scrolls back to its start
/// (<c>ScrollToShow(m_shopList, 0)</c>) and, on the retail caller's
/// "notify" paths, the FIRST item that passed the filter becomes the
/// selection UNCONDITIONALLY — there is no survival test
/// (<c>pc:201180-201184</c>: <c>if (arg3 != 0) SetSelectedObject(i_1, 0)</c>,
/// where <c>i_1</c> is simply the first matching item, 0/none if the
/// filter matched nothing). F6 confirmed all THREE of our call sites are
/// retail's notify=1 case, not notify=0: a fresh vendor open AND a
/// same-vendor post-buy/sell refresh both run
/// <c>VendorItemsUI::OpenVendor</c> UNCONDITIONALLY (<c>pc:203852</c>,
/// not gated on the sameVendor flag), which clamps the dropdown index
/// and calls <c>UIElement_Menu::SetSelectedItem(..., 1)</c>
/// (<c>pc:200783-201022</c>) — that trailing <c>1</c> is retail's
/// selection-changed notify flag, and setting the menu's selection
/// synchronously cascades into <c>UpdateItemsList(0, 1)</c> via
/// <c>gmVendorUI::ListenToElementMessage</c>'s <c>idMessage==7</c> case
/// (<c>pc:204302-204303</c>) — the SAME idMessage==7/notify=1 path a
/// manual category switch (<see cref="SelectCategory"/>) already takes.
/// Retail's ONLY notify=0 (no-reselect) call site is a bare tab-page-open
/// with no category/vendor change (<c>idMessage==0x2c</c>,
/// <c>m_OpenPageToken==0x100000bc</c>, <c>pc:204283-204285</c>) — this
/// controller has no equivalent call site (<see cref="ShowTab"/> never
/// calls this method), so every path that reaches
/// <see cref="RebuildItemList"/> is retail's notify=1 case. Slice 6.2:
/// retail routes the selection through the global
/// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>
/// (<c>pc:201184</c>) — this calls <see cref="SelectionState.Select"/>/
/// <see cref="SelectionState.Clear"/> instead of the retired private
/// field, so the toolbar status bar and slider light up for the
/// auto-selected item too.
/// </remarks>
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
: default;
uint maskValue = (uint)activeMask;
IReadOnlyList<VendorShopItem> items = _vendor.Items;
// Best-effort initial paint only — the OLD selection, captured
// before this rebuild. The unconditional Select/Clear call below is
// what actually decides the post-rebuild selection (F6); this just
// avoids a one-frame flash of "nothing highlighted" in the common
// case where the old and new selections turn out to be the same
// guid (SelectionState.Select is a no-op then and won't re-fire
// Changed to correct it).
uint? selectedGuid = _selection.SelectedObjectId;
VendorShopItem? firstItem = null;
bool selectedStillVisible = false;
using (_itemList.DeferLayout())
{
_itemList.Flush();
if (maskValue != 0u)
{
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
// captures, matching ExternalContainerController.
// CreateCell's exact resolve-icon call shape.
uint icon = _resolveIcon(
(ItemType)(item.ItemType ?? 0u),
item.IconId,
item.IconUnderlayId,
item.IconOverlayId,
item.Effects);
var cell = new UiItemSlot
{
SpriteResolve = _itemList.SpriteResolve,
SlotIndex = _itemList.GetNumUIItems(),
// F3 (Slice 6 review): a shop row must never become a
// drag source — see UiItemSlot.AllowDragSource's doc
// comment for why this must be gated at the source,
// not left to every destination handler to reject.
AllowDragSource = false,
};
cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == selectedGuid;
VendorShopItem captured = item;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
_itemList.AddItem(cell);
}
}
}
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);
}
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>,
/// <c>pc:202539-202820</c>). Slice 6.2: called ONLY from
/// <see cref="OnSelectionTransition"/>, once <paramref name="item"/> has
/// already been confirmed to be the globally-selected guid — this method
/// no longer writes the selection itself (<see cref="SelectionState"/>
/// is the authority; row clicks, the F4 auto-select fallback, and
/// right-click examine all call <see cref="SelectionState.Select"/>
/// directly and let this method react).
/// </summary>
/// <remarks>
/// F2/F3 (Slice 5.4 review), rewired for F2 (Slice 6 review): the
/// priced/named QUANTITY is retail's <c>ItemHolder::GetObjectSplitSize</c>
/// (<c>0x00586F00</c>, <c>pc:401465-401477</c>) read LIVE — retail
/// re-runs this SAME display update on every slider change
/// (<c>gmVendorUI::RecvNotice_StackSliderChanged</c>,
/// <c>pc:203262-203278</c>, <c>0x004C4500</c>, wired via
/// <see cref="OnSplitQuantityChanged"/> below), reading the singular/
/// plural gate, the count, and the price off the CURRENT split value
/// (<c>pc:202602</c>/<c>202621</c>/<c>202644</c>), not a value frozen at
/// selection time. <see cref="ResolveBuyQuantity"/> is the SAME
/// computation <see cref="BuySelectedItem"/> uses to decide what it
/// actually sends, so the displayed price always equals what a Buy press
/// would charge. <c>VendorSplitPolicy.SeedQuantity</c> stays only in
/// <see cref="SelectedObjectController"/>'s real seeding path (the
/// toolbar slider's INITIAL value); this display no longer reads it.
/// Name: <c>ACCWeenieObject::GetObjectName</c> (<c>0x0058E6E0</c>,
/// <c>pc:409056-409132</c>) — NAME_SINGULAR for quantity &lt;= 1,
/// NAME_PLURAL for quantity &gt; 1; when no plural is authored
/// (<c>m_len==1</c>, an empty <c>PString</c>) retail falls back to the
/// SINGULAR name UNCHANGED — read directly off <c>GetObjectName</c>'s
/// body (label <c>label_58e76b</c>'s <c>m_buffer-&gt;m_len != 1</c>
/// branch falls through to the plain <c>_name</c> read when false), NOT
/// the "likely name+s" guess. Cost text: <see cref="BuildCostText"/>.
/// Button state: no selection disables Buy/Add
/// (<c>SetState(0xd)</c>, <c>pc:202572-202577</c>); a selection enables
/// them (<c>SetState(1)</c>, <c>pc:202784-202789</c>) — modeled here as
/// <see cref="UiButton.Enabled"/>, the project-wide idiom for this
/// retail state pair (see <c>ToolbarController</c>'s
/// <c>_useButton.Enabled</c>).
/// </remarks>
private void ApplyItemDisplay(VendorShopItem item)
{
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
{
if (_itemList.GetItem(i) is { } cell)
cell.Selected = cell.ItemId == item.ItemGuid;
}
int quantity = (int)ResolveBuyQuantity(item);
string baseName = quantity <= 1
? item.Name ?? string.Empty
: (string.IsNullOrEmpty(item.PluralName) ? item.Name : item.PluralName) ?? string.Empty;
string nameText = quantity > 1 ? $"{quantity} {baseName}" : baseName;
SetPlainText(_itemNameText, nameText);
VendorShopProfile profile = _vendor.Profile;
int rawValue = item.Value ?? 0;
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
SetActionButtonsEnabled(true);
}
/// <summary>
/// Right-click examine on a shop row — mirrors
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
/// request appraisal" shape, matching retail's single-selection model
/// (research doc §B.2: no dedicated double-click mechanism, plain
/// select-then-act). Slice 6.2: routes through
/// <see cref="SelectionState.Select"/> — <see cref="OnSelectionTransition"/>
/// applies the display update, so this method no longer needs to search
/// <see cref="_vendor"/>'s items itself.
/// </summary>
private void ExamineItem(uint guid)
{
_selection.Select(guid, SelectionChangeSource.Vendor);
_itemInteraction.ExamineSelectedOrEnterMode(guid);
}
/// <summary>
/// Slice 6.2: reacts to ANY global selection change, not just ones this
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
/// Shows this panel's own price/name text for the newly-selected guid
/// when it is one of <see cref="_vendor"/>'s current items; clears the
/// panel's display otherwise (a selection made in some OTHER panel while
/// the vendor window is open must not leave stale vendor pricing text
/// on screen).
/// </summary>
private void OnSelectionTransition(SelectionTransition transition)
{
_ = 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>
/// F2 (Slice 6 review): retail's <c>gmVendorUI::RecvNotice_StackSliderChanged</c>
/// (<c>pc:203262-203278</c>, <c>0x004C4500</c>) — a registered listener
/// on the SAME global slider-changed notice
/// <see cref="SelectedObjectController"/> broadcasts, gated (in retail)
/// on the panel being visible and the globally-selected item being one
/// of the vendor's own. <see cref="RefreshSelectionDisplay"/> already
/// applies that same gate (it no-ops to <see cref="ClearSelectionDisplay"/>
/// when the current selection isn't a vendor item), so no separate
/// visibility check is needed here.
/// </summary>
private void OnSplitQuantityChanged() => RefreshSelectionDisplay();
private void RefreshSelectionDisplay()
{
uint? selected = _selection.SelectedObjectId;
if (selected is { } guid)
{
foreach (VendorShopItem item in _vendor.Items)
{
if (item.ItemGuid == guid)
{
ApplyItemDisplay(item);
return;
}
}
}
ClearSelectionDisplay();
}
/// <summary>
/// Slice 6.2: retail's <c>VendorItemsUI::RemoveFromShop</c>
/// (<c>pc:202848-202850</c>, <c>0x004c3d4a</c>) clears the global
/// selection when a shop item leaves the vendor's list — this mirrors
/// that (and, transitively, every other panel's own
/// <c>OnObjectRemoved</c>) rather than a vendor-specific "on close, set
/// selected to 0" special case. <c>VendorShopItemMaterializer</c>
/// removing every materialized item on session close/reset/replace
/// (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)
{
_selection.Clear(
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>
/// F2/F7 (Slice 6 review): the SINGLE quantity computation both the
/// display (<see cref="ApplyItemDisplay"/>) and the actual purchase
/// (<see cref="BuySelectedItem"/>) use — retail's
/// <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>,
/// <c>0x004C2820</c>): quantity 1 when the item's own authored stack
/// size is <c>&lt;= 1</c> (<c>pc:201674-201681</c>) — there is no split
/// UI for a non-stack item, so a leftover slider value from a
/// PREVIOUSLY-selected DIFFERENT stackable item must never leak into
/// this one — otherwise the CURRENT slider value via
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>).
/// <para>
/// R1 gate-finding fix, CORRECTED at the 2026-08-08 live-evidence
/// re-fix (register AP-169, second correction): the ceiling this gates
/// on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> —
/// <see cref="VendorShopItem.MaxStackSize"/> FIRST (retail's own
/// vendor-owned quantity operand: the Buy cases at
/// <c>pc:203996</c>/<c>204086</c> gate on <c>pwd._maxStackSize &gt; 1</c>
/// literally), falling back to <see cref="VendorShopItem.DescStackSize"/>
/// — the SAME resolution <see cref="VendorShopItemMaterializer"/> uses
/// to seed <see cref="ClientObject.StackSize"/> (and therefore the
/// toolbar slider's own ceiling, <c>SelectedObjectController</c>).
/// Using a narrower source here than the visible slider would let the
/// slider show a ceiling of 1000 while every Buy still sent quantity 1.
/// </para>
/// </summary>
private uint ResolveBuyQuantity(VendorShopItem item)
{
uint stackSize = (uint)VendorSplitPolicy.ResolveAuthoredStackSize(item.DescStackSize, item.MaxStackSize);
if (stackSize <= 1u)
return 1u;
uint selected = _selection.SelectedObjectId ?? item.ItemGuid;
return _splitQuantity.GetObjectSplitSize(item.ItemGuid, selected, stackSize);
}
/// <summary>
/// Cost sentence — <c>VendorItemsUI::UpdateItemsUI</c>'s tail
/// (<c>pc:202641-202790</c>). No-alt-currency: verb agreement ("costs"
/// for quantity &lt;= 1, "cost" for &gt; 1 — <c>pc:202759-202762</c>),
/// comma-grouped price/holdings (retail's <c>InsertCommas</c>, ported as
/// <c>ToString("N0")</c> with <see cref="CultureInfo.InvariantCulture"/>)
/// via the literal format <c>"%s %hsp (you have %hsp)"</c>
/// (<c>pc:202769</c>). Alt-currency: the FULL retail format string, read
/// directly off the data segment rather than the truncated call-site
/// view — <c>"This item costs %d %s. You have %d %s."</c>
/// (<c>acclient_2013_pseudo_c.txt:991314</c>) — this branch uses RAW
/// (not comma-grouped) integers, an asymmetry retail's own literal
/// format establishes, not an oversight here.
/// <para>
/// The player's primary-currency holding is retail's
/// <c>gmVendorUI::m_totalValue</c>, read from <c>PropertyInt.CoinValue</c>
/// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/>
/// generic int-property bundle every other PropertyInt-driven display
/// reads. The alt-currency holding is retail's
/// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c>;
/// <c>m_last_sale</c> only changes on a completed Slice-6 purchase, so
/// with no purchase mechanism yet this port uses
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> directly
/// (retail's <c>m_last_sale == 0</c> case — see the register, AP-161).
/// </para>
/// </summary>
private string BuildCostText(VendorShopProfile profile, int quantity, int price)
{
if (profile.AlternateCurrencyWcid != 0u)
{
return string.Format(
CultureInfo.InvariantCulture,
"This item costs {0} {1}. You have {2} {1}.",
price,
profile.AlternateCurrencyPluralName,
(int)profile.AlternateCurrencyAmount);
}
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
string verb = quantity <= 1 ? "costs" : "cost";
// G1 (vendor gate finding): retail's literal format is
// "%s %hsp (you have %hsp)" (pc:202769) — the trailing "p" after
// each %hs is a LITERAL pyreal-currency suffix character, not part
// of the specifier. The prior port read "%hsp" as one token and
// dropped both literal p's.
return string.Format(
CultureInfo.InvariantCulture,
"{0} {1}p (you have {2}p)",
verb,
price.ToString("N0", CultureInfo.InvariantCulture),
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
}
private void ClearSelectionDisplay()
{
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
{
if (_itemList.GetItem(i) is { } cell)
cell.Selected = false;
}
SetPlainText(_itemNameText, string.Empty);
SetPlainText(_itemCostText, string.Empty);
SetActionButtonsEnabled(false);
}
/// <summary>
/// Slice 6b (AP-161 F8 residual closes): "Add to List" now enables with
/// selection exactly like Buy — the "Buying" tab's staging list is
/// implemented, so an enabled Add is no longer a dead affordance.
/// </summary>
private void SetActionButtonsEnabled(bool enabled)
{
_buyEnabledBySelection = enabled;
RecomputeBuyButtonEnabled();
}
/// <summary>
/// Slice 6.3/6b: the Buy AND Add buttons' enabled state is the
/// CONJUNCTION of "is something selected" (<see cref="_buyEnabledBySelection"/>,
/// set by <see cref="SetActionButtonsEnabled"/>) and "is the shared
/// inventory/use gate free right now"
/// (<see cref="ItemInteractionController.CanMakeInventoryRequest"/>).
/// Add is selection-gated only (retail's <c>AddToBuyList</c> sends
/// nothing to the server — no busy-gate reason to disable it while a
/// Buy/Sell is in flight), but sharing this recompute keeps both buttons
/// consistent with a single call site. Called on every selection change
/// AND on every <see cref="ItemInteractionController.StateChanged"/>
/// tick, so Buy disables the instant <see cref="ItemInteractionController.TryBuy"/>
/// takes its reservation and re-enables on the matching completion — no
/// per-frame polling.
/// </summary>
private void RecomputeBuyButtonEnabled()
{
if (_buyButton is not null)
_buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest;
if (_addButton is not null)
_addButton.Enabled = _buyEnabledBySelection;
}
private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled();
/// <summary>
/// Slice 6.3: retail <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>).
/// Reads the CURRENT globally-selected shop item and dispatches a
/// single-item purchase through the shared use/inventory reservation,
/// using <see cref="ResolveBuyQuantity"/> — the SAME quantity
/// computation <see cref="ApplyItemDisplay"/> prices/names the selection
/// at (F2), so the amount actually sent always matches what was shown.
/// Client-side affordability/capacity pre-checks are deliberately NOT
/// ported (research doc's open question 1: the server is authoritative
/// either way and pre-checks are latency/UX polish, not correctness —
/// deferred as a fast follow-up if the round-trip lag on a refused
/// purchase is noticeable live).
/// </summary>
private void BuySelectedItem()
{
if (_selection.SelectedObjectId is not { } guid)
return;
VendorShopItem? selected = null;
foreach (VendorShopItem item in _vendor.Items)
{
if (item.ItemGuid == guid)
{
selected = item;
break;
}
}
if (selected is not { } shopItem)
return;
uint quantity = ResolveBuyQuantity(shopItem);
_itemInteraction.TryBuy(
_vendor.VendorId,
shopItem.ItemGuid,
(int)quantity,
_vendor.Profile.AlternateCurrencyWcid);
}
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
{
foreach (VendorShopItem item in _vendor.Items)
{
if (item.ItemGuid == guid)
{
shopItem = item;
return true;
}
}
shopItem = default;
return false;
}
/// <summary>
/// Slice 6b: Items-tab "Add to List" — <c>gmVendorUI::HandleButtonClicks</c>
/// case <c>0x100000c3</c> (<c>pc:203970-203988</c>). Stages the
/// globally-selected shop item at <see cref="ResolveBuyQuantity"/> — the
/// 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()
{
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
return;
uint quantity = ResolveBuyQuantity(shopItem);
if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped)
_systemMessage?.Invoke(VendorStagingList.TooMuchMessage);
}
/// <summary>
/// Retail's <c>Buy Item</c>/<c>Clear Item</c> (Buying tab) shared
/// removal-amount rule (<c>pc:203996</c>/<c>204086</c>): stackable -> -1
/// (full removal), else 1. Retail tests the item's own
/// <c>pwd._maxStackSize</c> (the item TYPE's stack ceiling) — now a
/// byte-exact port (register AP-165 RETIRED 2026-08-08, R1 gate
/// finding): <see cref="VendorShopItem.MaxStackSize"/> threads the wire
/// field through directly, so this no longer needs the
/// <see cref="VendorShopItem.DescStackSize"/> substitute the row
/// originally filed.
/// </summary>
private static int BuyStagingRemovalAmount(VendorShopItem item) =>
(item.MaxStackSize ?? 1) > 1 ? -1 : 1;
/// <summary>
/// Slice 6b: "Buying" tab's "Buy Item" — retail case <c>0x100000c9</c>
/// (<c>pc:203989-204010</c>). Reuses the SAME immediate single-item
/// <c>BuySingleItem</c> path (<see cref="ItemInteractionController.TryBuy"/>)
/// the Items tab's own Buy button uses — reads the GLOBAL slider
/// quantity, not the staged entry's own quantity — then on a successful
/// dispatch removes the staged entry (<see cref="BuyStagingRemovalAmount"/>).
/// </summary>
private void BuyItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
return;
uint quantity = ResolveBuyQuantity(shopItem);
if (_itemInteraction.TryBuy(
_vendor.VendorId,
shopItem.ItemGuid,
(int)quantity,
_vendor.Profile.AlternateCurrencyWcid))
{
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
}
}
/// <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>, <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, narrowed
/// G1 gate-finding fix 2026-08-08).</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. This residual applies ONLY to the shop-stock side
/// (<see cref="ComputeBuySlotsNeeded"/>) — <see cref="VendorShopItem"/>
/// genuinely has no wire-carried classification field to read instead.
/// The player's-OWN-pack side (<see cref="CountPlayerContents"/>) no
/// longer shares this approximation: it now reads
/// <see cref="ClientObject.ContainerTypeHint"/> (retail's actual wire
/// <c>ContainerProperties</c>, already threaded onto every owned object)
/// first, matching retail's real <c>_itemsList</c>/<c>_containersList</c>
/// bucketing exactly for anything that ever received a hint. Live
/// testing showed the OLD dual-heuristic (also checking nonzero
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c>) could over-classify a
/// non-container object as an occupied container slot and false-block a
/// purchase with real free slots — 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.
/// </summary>
private void BuyAllButtonPressed()
{
if (_buyStaging.IsEmpty)
return;
var items = new List<(int Amount, uint ItemGuid)>(_buyStaging.Entries.Count);
foreach (VendorStagingEntry entry in _buyStaging.Entries)
items.Add((entry.Quantity, entry.ItemGuid));
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>
/// R3: the Selling tab's total-proceeds counterpart to
/// <see cref="ComputeBuyTransactionValue"/> — retail
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202380-202468</c>),
/// which prices each staged row via <c>VendorProfile::VendorBuyPrice</c>
/// (the rate the VENDOR pays when IT buys FROM the player — see
/// <see cref="VendorPricing"/>'s naming-inversion warning). Unlike the
/// Buying side (whose priced item lives in <see cref="_vendor"/>'s shop
/// list), a staged SELL entry's item is the PLAYER's OWN pack item —
/// <see cref="_objects"/>, the same source <see cref="RebuildSellingList"/>
/// already reads for icon/type data.
/// </summary>
private int ComputeSellTransactionValue()
{
VendorShopProfile profile = _vendor.Profile;
int total = 0;
foreach (VendorStagingEntry entry in _sellStaging.Entries)
{
if (_objects.Get(entry.ItemGuid) is not { } item)
continue;
int perUnit = VendorPricing.PerUnitValue(item.Value, item.StackSize);
total += VendorPricing.BuyPrice(perUnit, (uint)item.Type, profile.BuyPrice, entry.Quantity);
}
return total;
}
/// <summary>
/// R3 (user-requested retail presentation, closing AP-166's text half —
/// grand-gate live evidence: a selected vendor taper shows the Buying
/// tab's right-side summary "Buying 2 items worth 422p" / "You have
/// 23p"). Retail's exact literal, recovered BYTE-VERBATIM: the Sell side
/// is directly legible in the decompiled body of
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202458</c>,
/// <c>u"Selling %d %s worth %hsp"</c>); the Buy side's IDENTICALLY-SHAPED
/// literal is mis-attributed by the decompiler to a bogus vtable-slot
/// symbol at its own call site (<c>VendorBuyUI::UpdateTransactionValue</c>,
/// <c>pc:202290</c>) — recovered instead by reading the binary's own
/// data segment directly (<c>C:\Users\erikn\Downloads\acclient.exe</c>,
/// the Sept 2013 EoR build paired with <c>refs/acclient.pdb</c>), which
/// carries the wide string <c>"Buying %d %s worth %hsp"</c> at VA
/// <c>0x007b58bc</c> (Sell's own literal likewise resolves at
/// <c>0x007b5930</c>, confirming both strings byte-for-byte). The "p"
/// after <c>%hs</c> is a LITERAL pyreal-currency suffix character —
/// same convention as <see cref="BuildCostText"/>'s own
/// <c>"{0} {1}p (you have {2}p)"</c> — not part of the specifier.
/// Singular/plural ("item"/"items") gates on the STAGED COUNT (the sum
/// of every entry's quantity), matching retail's own per-row
/// <c>_stackSize</c> accumulator feeding the same singular/plural test.
/// <para>
/// Alt-currency (a rare trade-note vendor): the Buy side's exact literal
/// IS confirmed directly legible in the decompiled body
/// (<c>VendorBuyUI::UpdateTotalValue</c>, <c>pc:202344</c>,
/// <c>"You have %d %s."</c>) for the PURSE line; this method's alt-
/// currency LIST-line construction is a faithful EXTRAPOLATION of the
/// confirmed pyreal shape (dropping the "p" suffix, substituting the
/// currency's plural name for the value) — the exact alt-currency LIST
/// format string was not independently recovered byte-verbatim. See the
/// register, AP-166, for this narrow residual.
/// </para>
/// </summary>
private static string BuildTransactionListText(
string verb, int count, int totalValue, VendorShopProfile profile)
{
string noun = count == 1 ? "item" : "items";
if (profile.AlternateCurrencyWcid != 0u)
{
return string.Format(
CultureInfo.InvariantCulture,
"{0} {1} {2} worth {3} {4}",
verb,
count,
noun,
totalValue,
profile.AlternateCurrencyPluralName);
}
return string.Format(
CultureInfo.InvariantCulture,
"{0} {1} {2} worth {3}p",
verb,
count,
noun,
totalValue.ToString("N0", CultureInfo.InvariantCulture));
}
/// <summary>
/// R3: shared "You have %hsp" purse-text builder — retail
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202366</c>,
/// <c>u"You have %hsp"</c>) and <c>VendorSellUI::UpdateTotalValue</c>
/// (<c>pc:202495</c>, the SAME literal <c>u"You have %hsp"</c>) — both
/// directly legible in the decompiled body, byte-identical. Comma
/// grouping matches retail's own <c>InsertCommas</c> call immediately
/// before this format runs. The alt-currency branch's literal
/// <c>"You have %d %s."</c> is directly legible at
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202344</c>); the Sell
/// side's alt-currency purse text was not independently traced but is
/// presumed identical by symmetry — both read the SAME
/// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c> holding
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> already
/// substitutes for elsewhere (see the register, AP-161's <c>m_last_sale</c>
/// residual, and AP-166 for this untraced half).
/// </summary>
private string BuildPurseText(VendorShopProfile profile)
{
if (profile.AlternateCurrencyWcid != 0u)
{
return string.Format(
CultureInfo.InvariantCulture,
"You have {0} {1}.",
(int)profile.AlternateCurrencyAmount,
profile.AlternateCurrencyPluralName);
}
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
return string.Format(
CultureInfo.InvariantCulture,
"You have {0}p",
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
}
/// <summary>
/// R3: repaints the Buying tab's own summary text — retail
/// <c>VendorBuyUI::Update</c> (<c>pc:202996-203005</c>) calls
/// <c>UpdateTransactionValue</c> then <c>UpdateTotalValue</c>
/// unconditionally on every staging mutation.
/// </summary>
private void UpdateBuyTransactionText()
{
if (_buyListText is null && _buyPurseText is null)
return;
VendorShopProfile profile = _vendor.Profile;
int count = _buyStaging.Entries.Sum(e => e.Quantity);
int totalValue = ComputeBuyTransactionValue();
if (_buyListText is not null)
SetPlainText(_buyListText, BuildTransactionListText("Buying", count, totalValue, profile));
if (_buyPurseText is not null)
SetPlainText(_buyPurseText, BuildPurseText(profile));
}
/// <summary>
/// R3: the Selling tab's counterpart to <see cref="UpdateBuyTransactionText"/> —
/// retail <c>VendorSellUI::Update</c> (<c>pc:203009-203018</c>), same shape.
/// </summary>
private void UpdateSellTransactionText()
{
if (_sellListText is null && _sellPurseText is null)
return;
VendorShopProfile profile = _vendor.Profile;
int count = _sellStaging.Entries.Sum(e => e.Quantity);
int totalValue = ComputeSellTransactionValue();
if (_sellListText is not null)
SetPlainText(_sellListText, BuildTransactionListText("Selling", count, totalValue, profile));
if (_sellPurseText is not null)
SetPlainText(_sellPurseText, BuildPurseText(profile));
}
/// <summary>
/// R3: a player money change (purchase/sale/pickup/drop elsewhere) must
/// repaint BOTH tabs' purse line even when staging itself is unchanged —
/// retail's own purse text always reads the LIVE holding fresh (same as
/// <see cref="BuildCostText"/>'s own <c>PropertyInt.CoinValue</c> read),
/// with no separate "did staging change" gate.
/// </summary>
private void OnObjectMoneyChanged(ClientObject updated)
{
if (updated.ObjectId != _playerGuid())
return;
UpdateBuyTransactionText();
UpdateSellTransactionText();
}
/// <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.
/// <para>
/// R1 gate-finding fix (2026-08-08): retail's stackable test at this
/// exact call site is <c>eax-&gt;pwd._maxStackSize &lt;= 1</c>
/// (<c>pc:200052</c>) — literally <c>MaxStackSize</c>, never
/// <c>_stackSize</c>. This now reads <see cref="VendorShopItem.MaxStackSize"/>
/// directly, a byte-exact port now that the wire field is threaded
/// through (previously approximated with <c>DescStackSize</c>, which
/// ACE never populates for a browse-list row, so a stackable item was
/// always misclassified as non-stackable — see AP-169's sibling
/// finding).
/// </para>
/// </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.MaxStackSize ?? 1) > 1;
if (stackable)
{
if (isContainer) containerSlots += 1; else itemSlots += 1;
}
else
{
if (isContainer) containerSlots += amount; else itemSlots += amount;
}
}
return (itemSlots, containerSlots);
}
/// <summary>
/// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied
/// item/container slot counts. Retail's own <c>GetNumContainedItems</c>/
/// <c>GetNumContainedContainers</c> (<c>0x0058beb0</c>/<c>0x0058bec0</c>)
/// don't reclassify anything at count time — they just report the length
/// of two ALREADY-BUCKETED <c>IDList</c>s (<c>_itemsList</c>/
/// <c>_containersList</c>). The bucketing happens once, at INSERT time
/// (<c>ACCWeenieObject::ServerSaysContainID @ 0x0058be40</c>), from the
/// wire's own <c>ContainerProperties</c> field (<c>Item_ServerSaysContainId</c>
/// 0x0022's <c>ContainerType</c>; also carried by <c>ContentProfile</c> /
/// <c>PlayerDescription</c>'s per-entry container-kind byte) — a
/// None/Container/Foci discriminator the SERVER computes, not something
/// the client reverse-engineers from the item's own type bits.
/// <see cref="ClientObject.ContainerTypeHint"/> is exactly that wire
/// field, already threaded through every membership path
/// (<c>InitializeInventoryManifest</c>, <c>InventoryPutObjInContainer</c>,
/// <c>ViewContents</c>) and already used for this identical
/// container-vs-item question elsewhere
/// (<c>ClientObjectTable.IsContainerListMember</c>). This port previously
/// used ONLY the local <see cref="ItemType.Container"/>/capacity-field
/// heuristic here (AP-168) and never consulted the hint — live testing
/// showed that guessing wrong in the OVER-classify direction (a
/// non-container object whose capacity fields happen to read nonzero)
/// false-blocks a purchase with real free slots (#G1). The hint is now
/// authoritative when present; the heuristic is a narrower fallback
/// (Container-typed only, matching <see cref="ComputeBuySlotsNeeded"/>'s
/// single signal) for the rare object that reached the table without
/// ever threading a hint.
/// </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.ContainerTypeHint != 0u || (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"
/// makes (<c>label_4c52ea</c>) but WITHOUT buying first — pure removal,
/// no transaction.
/// </summary>
private void BuyClearItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid)
return;
int amount = TryFindShopItem(guid, out VendorShopItem shopItem)
? BuyStagingRemovalAmount(shopItem)
: -1;
_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>), 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 || _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[] { (1, guid) }))
_sellStaging.Remove(guid, -1);
}
/// <summary>
/// Slice 6c: "Selling" tab's "Sell All" — retail case <c>0x100000d3</c>
/// (<c>pc:204113-204129</c>). Sends every staged entry as ONE batched
/// Sell. On a successful dispatch the whole staged list is flushed
/// unconditionally and immediately (the shared tail <c>label_4c560a</c>
/// also reached by "Clear List" — <c>FlushSellListSellState</c> +
/// <c>PackableList::Flush(&amp;m_sellList)</c>), the same optimistic
/// clear-right-after-send shape as <see cref="BuyAllButtonPressed"/>.
/// </summary>
private void SellAllButtonPressed()
{
if (_sellStaging.IsEmpty)
return;
var items = new List<(int Amount, uint ItemGuid)>(_sellStaging.Entries.Count);
foreach (VendorStagingEntry entry in _sellStaging.Entries)
items.Add((entry.Quantity, entry.ItemGuid));
if (_itemInteraction.TrySell(_vendor.VendorId, items))
_sellStaging.Clear();
}
/// <summary>
/// Slice 6c: "Selling" tab's "Clear Item" — retail case <c>0x100000d4</c>
/// (<c>pc:204131-204137</c>): always a full removal (<c>0xffffffff</c>),
/// no transaction. Retail also clears the item's "pending sell"
/// highlight in the player's own inventory panel
/// (<c>VendorItemSetSellState</c>) — that cross-panel highlight is a
/// deliberately unported cosmetic (see the register).
/// </summary>
private void SellClearItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid)
return;
_sellStaging.Remove(guid, -1);
}
/// <summary>
/// Renders <see cref="_buyStaging"/> into the "Buying" tab's own item
/// strip — icon cells identical in shape to the Items list's own (the
/// SAME <c>0x1000033A</c> cell-template prototype, per the G2 vendor-gate
/// finding), selectable so <see cref="BuyItemButtonPressed"/>/
/// <see cref="BuyClearItemButtonPressed"/> can read the globally-selected
/// guid. A staged guid that has left <see cref="_vendor"/>'s current shop
/// list (a rare race, e.g. mid-refresh) is skipped rather than crashing.
/// </summary>
private void RebuildBuyingList()
{
if (_buyingList is not { } list)
return;
uint? selectedGuid = _selection.SelectedObjectId;
using (list.DeferLayout())
{
list.Flush();
foreach (VendorStagingEntry entry in _buyStaging.Entries)
{
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem shopItem))
continue;
uint icon = _resolveIcon(
(ItemType)(shopItem.ItemType ?? 0u),
shopItem.IconId,
shopItem.IconUnderlayId,
shopItem.IconOverlayId,
shopItem.Effects);
var cell = new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
AllowDragSource = false,
};
cell.SetItem(shopItem.ItemGuid, icon);
cell.Selected = shopItem.ItemGuid == selectedGuid;
VendorShopItem captured = shopItem;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
list.AddItem(cell);
}
}
}
/// <summary>
/// Renders <see cref="_sellStaging"/> into the "Selling" tab's own item
/// strip. Unlike <see cref="RebuildBuyingList"/>, a staged sell entry's
/// icon/type data comes from <see cref="_objects"/> (the PLAYER's own
/// pack item), not <see cref="_vendor"/>'s shop list — the item was
/// dragged FROM the player's inventory, never authored as vendor stock.
/// </summary>
private void RebuildSellingList()
{
if (_sellingList is not { } list)
return;
uint? selectedGuid = _selection.SelectedObjectId;
using (list.DeferLayout())
{
list.Flush();
foreach (VendorStagingEntry entry in _sellStaging.Entries)
{
if (_objects.Get(entry.ItemGuid) is not { } item)
continue;
uint icon = _resolveIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
var cell = new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
AllowDragSource = false,
};
cell.SetItem(item.ObjectId, icon);
cell.Selected = item.ObjectId == selectedGuid;
uint captured = item.ObjectId;
cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor);
list.AddItem(cell);
}
}
}
// ── IItemListDragHandler (Slice 6c: the Selling tab's list is the SOLE
// drop target — gmVendorUI::HandleDropRelease routes every drag release
// anywhere in the panel through one IsAncestorOfMe(target, m_sellShopList)
// gate, pc:204229-204246) ──────────────────────────────────────────────
/// <summary>
/// The Selling list never sources a drag of its own — every staged cell
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same
/// non-drag-source convention every vendor row uses — so
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the
/// SOURCE list's own registered handler) can never actually reach this
/// method in practice. Implemented as a no-op for interface completeness.
/// </summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
}
public ItemDragAcceptance OnDragOver(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
return ItemDragAcceptance.Reject;
return EvaluateSellAcceptability(payload.ObjId, out _) == VendorSellRejection.None
? ItemDragAcceptance.Accept
: 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
/// <c>silent=0</c>, showing a rejection string) chained into
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
/// success: auto-switch to the "Selling" tab, globally select the
/// dropped item, stage it. Purely client-local — sends nothing to the
/// server, matching the Buying tab's "Add to List".
/// </summary>
public void HandleDropRelease(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
return;
VendorSellRejection rejection = EvaluateSellAcceptability(payload.ObjId, out int quantity);
if (rejection != VendorSellRejection.None)
{
if (VendorSellAcceptability.MessageFor(rejection) is { } message)
_systemMessage?.Invoke(message);
return;
}
ShowTab(VendorPanelTab.Selling);
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
_sellStaging.Add(payload.ObjId, quantity);
}
/// <summary>
/// Shared drag-over/drop gate — <see cref="VendorSellAcceptability.Evaluate"/>
/// 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.
/// <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)
{
quantity = 1;
if (_objects.Get(itemGuid) is not { } item)
return VendorSellRejection.WrongType;
bool ownedByPlayer = _itemInteraction.IsOwnedByPlayer(itemGuid);
int containedItemCount = _objects.GetContents(itemGuid).Count;
int perUnitValue = VendorPricing.PerUnitValue(item.Value, item.StackSize);
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer,
containedItemCount,
(uint)item.Type,
perUnitValue,
_vendor.Profile.MerchandiseItemTypes,
_vendor.Profile.MerchandiseMinValue,
_vendor.Profile.MerchandiseMaxValue,
item.PublicWeenieBitfield ?? 0u);
if (rejection == VendorSellRejection.None)
quantity = (int)Math.Max(1, item.StackSize);
return rejection;
}
/// <summary>
/// G4/Slice 6b: port of retail's close/pushpin button handler —
/// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case
/// (<c>pc:204147-204182</c>). Retail branches on whether the
/// Buying/Selling staging lists (<c>m_buyList</c>/<c>m_sellList</c>) hold
/// anything uncommitted: with nothing pending it calls ONLY
/// <c>this-&gt;vtable-&gt;SetVisible(0)</c> — a plain window hide, NOT
/// <c>gmVendorUI::CloseVendor</c> (<c>pc:202080</c>, the range-watcher-
/// unregister/session-teardown function <see cref="VendorState.Close"/>
/// ports). Only when something IS pending, and only when no confirmation
/// is already up (<c>gmVendorUI::m_curDialogContext == 0</c>,
/// <c>pc:204155</c>), does retail show the confirmation dialog ("You have
/// not completed all transactions. Are you sure you want to leave this
/// vendor" — the exact string, read from the decompiled binary's data
/// segment at <c>0x007b5bd8</c>, resolving the Slice 6b/6c research doc's
/// truncated "…" citation). Its Yes callback
/// (<c>gmVendorUI::CloseVendorDialogCallback</c>, <c>pc:202104-202166</c>)
/// reaches <c>CM_Vendor::SendNotice_CloseVendor</c> — an internal
/// notice-bus fanout, not a network send (class doc's A.4 citation) —
/// and nothing in that call chain touches <c>m_buyList</c>/<c>m_sellList</c>,
/// so staging survives a "Yes, leave anyway" exactly like retail: the
/// window hides, the staged items are still there next time the vendor
/// is reopened. A "No" (or dismissing the dialog) leaves the window open
/// with staging untouched.
/// </summary>
/// <remarks>
/// <b>Behavior change from the prior port.</b> This button used to call
/// <see cref="VendorState.Close"/> directly — a full session teardown
/// (VendorId/Profile/Items cleared, every materialized shop item
/// retired) on every ordinary close, which retail does NOT do. The
/// session now stays open in the background exactly like retail's
/// hidden-but-still-registered range watcher:
/// <see cref="RuntimeVendorRangeQuery.EnforceRange"/> is evaluated
/// unconditionally every frame regardless of window visibility (it reads
/// only <see cref="VendorState.VendorId"/>, never this window's
/// <c>IsVisible</c>), so leaving <c>UseRadius</c> still converges to a
/// full <see cref="VendorState.Close"/> exactly as before. Re-approaching
/// the SAME vendor before then now lands on retail's <c>sameVendor==1</c>
/// refresh-in-place path (<see cref="VendorStateTransitionKind.Refreshed"/>,
/// 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?";
private void CloseButtonPressed()
{
if (_buyStaging.IsEmpty && _sellStaging.IsEmpty)
{
_window.Hide();
return;
}
// No dialog infra wired (a minimal test harness), or a confirmation
// is already up — retail's m_curDialogContext==0 gate (pc:204155).
if (_dialogs is null || _closeConfirmContext != 0u)
return;
_closeConfirmContext = _dialogs.MakeDialog(
RetailDialogData.Confirmation(CloseConfirmationMessage),
result =>
{
_closeConfirmContext = 0u;
if (result.GetBoolean(RetailDialogProperty.ConfirmationResult))
_window.Hide();
});
}
/// <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();
_selectedCategoryIndex = -1;
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
_typeMenu.Selected = null;
_itemList.Flush();
// Local widget hygiene only — does NOT touch the global selection.
// This runs from the constructor (before any vendor is ever open)
// and cannot assume whatever SelectionState.SelectedObjectId
// currently holds belongs to this panel. The actual "vendor session
// close clears a vendor-owned selection" behavior is OnObjectRemoved
// reacting to VendorShopItemMaterializer's removal, not this method.
ClearSelectionDisplay();
// R3: reset the Buying/Selling summary text to its empty-staging
// shape (retail's own text is never simply blanked — UpdateTotalValue
// still renders "You have Np" etc. with a zero transaction) on both
// constructor-time setup and session close/reset.
UpdateBuyTransactionText();
UpdateSellTransactionText();
}
/// <summary>
/// G2 (vendor gate finding): mirrors the Items strip's empty-slot-fill
/// configuration (see the constructor's <c>_itemList</c> block) for the
/// Buying/Selling pages' own authored lists — presentation only. The
/// list is flushed once (dropping the single default cell every
/// <see cref="UiItemList"/> constructs itself with) so every visible
/// cell comes from <paramref name="list"/>'s own
/// <see cref="UiItemList.EmptySlotFactory"/> with consistent styling
/// (non-drag-source), then left alone: nothing ever calls
/// <see cref="UiItemList.AddItem"/> on it, so
/// <see cref="UiItemList.LayoutCells"/>'s empty-slot padding
/// (<c>UpdateEmptySlots</c>) is the ONLY thing that ever populates it.
/// </summary>
private static void ConfigureEmptyStrip(UiItemList? list, uint emptySlotSprite)
{
if (list is null) return;
list.Flush();
list.Columns = 1;
list.SingleRow = true;
list.HorizontalScroll = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.FillVisibleEmptySlots = true;
if (emptySlotSprite != 0u)
list.CellEmptySprite = emptySlotSprite;
list.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
AllowDragSource = false,
};
}
private static void SetPlainText(UiText text, string value)
{
IReadOnlyList<UiText.Line> lines = string.IsNullOrEmpty(value)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(value, text.DefaultColor) };
text.LinesProvider = () => lines;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_vendor.Changed -= OnVendorChanged;
_selection.Changed -= OnSelectionTransition;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectMoneyChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_buyStaging.Changed -= RebuildBuyingList;
_buyStaging.Changed -= RefreshItemsTabAvailability;
_buyStaging.Changed -= UpdateBuyTransactionText;
_sellStaging.Changed -= RebuildSellingList;
_sellStaging.Changed -= UpdateSellTransactionText;
DismissCloseConfirmationIfOpen();
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
RetailTabBinding.SetClick(_itemsTab, null);
RetailTabBinding.SetClick(_buyingTab, null);
RetailTabBinding.SetClick(_sellingTab, null);
_typeMenu.OnSelect = null;
_typeMenu.ButtonLabelProvider = null;
_itemList.ExamineItemRequested = null;
if (_close is not null)
_close.OnClick = null;
if (_buyButton is not null)
_buyButton.OnClick = null;
if (_addButton is not null)
_addButton.OnClick = null;
if (_buyItemButton is not null)
_buyItemButton.OnClick = null;
if (_buyAllButton is not null)
_buyAllButton.OnClick = null;
if (_buyClearItemButton is not null)
_buyClearItemButton.OnClick = null;
if (_buyClearListButton is not null)
_buyClearListButton.OnClick = null;
if (_sellItemButton is not null)
_sellItemButton.OnClick = null;
if (_sellAllButton is not null)
_sellAllButton.OnClick = null;
if (_sellClearItemButton is not null)
_sellClearItemButton.OnClick = null;
if (_sellClearListButton is not null)
_sellClearListButton.OnClick = null;
}
}