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;
///
/// Retained port of retail gmVendorUI's "Items" tab
/// (VendorItemsUI) — the browse-only scope of Slice 5.4. LayoutDesc
/// 0x21000012, root 0x100000B7 — discovered and verified
/// against the named-retail decomp in
/// docs/research/2026-08-08-slice5-vendor-browse-research.md §B.3/§B.4.
///
///
/// Three tabs, not two. 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" (),
/// "Buying" (), "Selling" () —
/// each a state/page pair. Retail's gmVendorUI::OpenVendor
/// (pc:203650) opens tab 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"
/// (VendorBuyUI, staged-purchase review) and "Selling"
/// (VendorSellUI, 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
/// InqAcceptability, no currency mutation happens anywhere in this
/// file.
///
///
///
/// Category filter = a dropdown, not more tabs. Retail's
/// VendorItemsUI::AddTypeFilter/::ListContainsType
/// (pc:199667/200132, D0 read in the research doc's §B.4)
/// populate with one
/// entry per 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).
///
///
///
/// Lifecycle: pure state projection. This controller owns no
/// session/transaction state of its own. It opens/repopulates/closes purely
/// by observing . The close button (G4,
/// vendor gate finding) does NOT mutate at all —
/// see for the retail citation
/// (gmVendorUI::HandleButtonClicks's 0x100000d6 case,
/// pc:204147-204182): with nothing staged it is a plain window hide,
/// leaving the session open in the background exactly like retail's
/// still-registered range watcher. —
/// client-local per research §A.4, nothing on the wire — is reached only via
/// 's distance check, never
/// from a direct field write here.
///
///
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;
///
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
/// Retail UIElement_Menu::MakePopup (0x0046D310,
/// pc:120705-120764) reads a PER-MENU popup LayoutDesc
/// from element attribute 7 (DataID) and a root element id from
/// attribute 6 (Enum); Initialize (0x0046D440,
/// pc:120789-120828) then locates the popup's
/// UIElement_ListBox child via attribute 2. Element
/// 0x100000BF's resolved attributes — verified against the
/// committed fixture (vendor_21000012_100000B7.json, property
/// state 4294967295) AND a live-dat scan of LayoutDesc
/// 0x21000043 — are 7=0x21000043 (popup catalog layout),
/// 6=0x1000034F (popup root), 2=0x10000350 (its
/// ListBox), 9=0x10000352 (the row-template element the
/// dropdown's rows inherit from). NOTE: the original review's citation
/// of 6=0x1000014F was a transcription slip — the fixture and a
/// live-dat re-scan both read 0x1000034F.
///
/// draws its OWN simplified column-major grid, not
/// retail's nested ListBox tree, so this port reads off the
/// AUTHORED VALUES instead of instantiating that subtree: row size
/// 100x18 (0x10000352's own Width/Height), 6 rows per column
/// (the ListBox 0x10000350's own Height 108 / row height 18),
/// and the row sprites 0x060012B3 (Normal)/0x060012B4
/// (Highlight) — INSTEAD of reusing 's class-default
/// constants, which are chat's own authored values (191x17,
/// RowsPerColumn 7, sprites 0x0600124C/4D/4E: chat's channel menu
/// (element 0x10000014 in LayoutDesc 0x21000006) has the
/// SAME three attributes pointing at a completely different, bespoke
/// popup subtree it authors within its own layout —
/// 7=0x21000006 (its own layout id), 6=0x1000001C,
/// 9=0x1000001E. This keeps
/// byte-identical (it is not touched by this fix) while giving
/// vendor's dropdown its own authored geometry instead of chat's.
///
///
/// The popup root 0x1000034F carries NO direct-state sprite of
/// its own (unlike chat's 0x0600124C panel fill) — each row
/// tiles its own background with no gap between rows (6 rows x 18px =
/// the ListBox's exact 108px height), so
/// is left at its default 0
/// ('s sprite draw helper no-ops on id 0), matching
/// the absent authored sprite exactly rather than inventing one.
///
///
/// G5 correction (vendor gate finding): it is a SCROLLABLE single
/// column, not a 3-column grid. The F1 review's "column-major grid"
/// framing was wrong — a live-dat scan (tools/VendorLayoutScan,
/// dump/resolved 0x21000043 0x1000034F) shows
/// 0x1000034F has TWO children, not one: the ListBox
/// 0x10000350 (100x108, resolved attribute 0x5E=6/
/// 0x5F=1 — six rows, ONE column) AND a SIBLING
/// UIElement_Scrollbar (class 0xB, element
/// 0x10000351, 16x108, docked at X=100 immediately right of the
/// list, with a real thumb/up/down-button subtree matching
/// 's own shape exactly: thumb caps
/// 0x06004C60/63/66, up button (element
/// 0x10000071) 0x06004C69/6A/6B, down button
/// (element 0x10000072) 0x06004C6C/6D/6E,
/// track 0x06004C5F). 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). switches the popup to this
/// shape; 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 0x21000006, element 0x1000001C) has NO
/// sibling scrollbar element and is unaffected —
/// never sets Scrollable, so
/// it keeps the original grid path byte-identical.
///
///
/// The button FACE reuses the same two sprites: 0x060012B3 is
/// literally what vendor's OWN button-face child (0x1000034D)
/// resolves to in the fixture, and 0x060012B4 (the row
/// template's "Highlight" state) is the paired open/pressed look. This
/// remains a deliberate, bounded choice — 's
/// single-texture 3-slice DrawButtonFace (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.
///
///
/// G6 (vendor gate finding, item 1 — the missing green arrow
/// indicator): closes the arrow-cap half of AP-161's residual.
/// Retail's actual closed-button chrome IS a separate two-piece
/// label+arrow assembly — label 0x1000034D (100x18, HJustify
/// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
/// 0x1000034E docked at the button's right edge (X=100,Y=0
/// within the 117-wide button), with its own Normal/Highlight states
/// resolving to 0x060012B1 (closed)/0x060012B2 (open) —
/// verified live via tools/VendorLayoutScan's resolved
/// command reading the element's two StateMedia images directly out of
/// client_local_English.dat. /
/// 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).
///
///
/// G7 (vendor gate finding, item 2 — the popup opened upward instead
/// of downward). Retail's UIElement_Menu::Open
/// (pc:120210-120252, 0x0046cc30) places the popup at
/// ScreenY1(button) (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 ScreenY0(button) - popupHeight (ABOVE).
/// UIElement::GetAttribute_Bool (pc:106749-106778) defaults
/// an ABSENT attribute to false. Element 0x100000BF's resolved
/// attribute bag (the same fixture cited throughout this doc) carries NO
/// property "5" at all — unlike chat's channel menu (0x10000014 in
/// LayoutDesc 0x21000006), which explicitly authors property "5" =
/// true. So retail opens vendor's dropdown DOWNWARD and chat's
/// UPWARD — two different authored choices, not one hardcoded direction.
/// (default true, preserving
/// chat's untouched behavior) is set false here to match.
/// Retail's Open 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 's own
/// doc for the citation.
///
///
/// G8 (vendor gate finding, item 3 — popup/button text overflowing
/// instead of sitting flush left).
/// (19px) and (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
/// (0x10000352, live-dat verified HJustify=Left) has no
/// checkbox child at all, and vendor's button label child
/// (0x1000034D) is itself HJustify=Left at X=0 with no LED
/// art — so both indents are set to 0 here, retail's own left-justified
/// UiText convention for an icon-less label (every OTHER
/// dat-driven UiText in this codebase sets Padding=0f).
/// 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 (Font 0x40000000,
/// live-measured via tools/VendorLayoutScan measure) —
/// 19+92=111px > the 100px row, an 11px overflow; with 0px it fits
/// with 8px to spare.
///
///
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;
///
/// Retail's ordered category table, transcribed verbatim from
/// VendorItemsUI::OpenVendor's AddTypeFilter call chain
/// (pc:200792-201005). 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 the research doc's §B.4 table.
///
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 _resolveIcon;
private readonly ClientObjectTable _objects;
private readonly Func _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? _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 resolveIcon,
ClientObjectTable objects,
Func 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? systemMessage,
UiDatFont? datFont,
BitmapFont? debugFont,
Func 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;
}
///
/// F2/F3 (Slice 5.4 review): the local player's coin total
/// (PropertyInt.CoinValue) lives here — the same generic
/// ClientObjectTable int-property bundle every other
/// PropertyInt-driven display reads, populated by
/// ObjectTableWiring's PrivateUpdatePropertyInt routing.
///
/// Resolves the local player's guid to look up in .
///
/// Slice 6.1: the shared retail item interaction orchestrator — its
/// ExamineSelectedOrEnterMode is what right-click-examine on a
/// shop row routes through, mirroring
/// 's own examine wiring.
///
///
/// Slice 6.2: the canonical — now the
/// AUTHORITY for shop-row selection (row clicks, the F4 auto-select
/// fallback, and right-click examine all call
/// directly); this panel only
/// listens and reacts, the same way every sibling panel does.
///
///
/// Slice 6.3: the shared toolbar stack-quantity control (retail
/// GenItemHolder::splitSize/maxSplitSize) — the Buy button
/// reads the SAME live value
/// seeds/the player adjusts via ItemHolder::GetObjectSplitSize
/// (0x00586F00), matching retail's BuySingleItem
/// (pc:201674-201681: quantity 1 if _stackSize <= 1,
/// else the current slider value).
///
/// Retail dat font for the category dropdown's button/row labels.
/// Fallback debug bitmap font (used when is null).
/// Dat RenderSurface id → (GL tex handle, px width, px height).
/// Authored empty-slot background for the item strip, or 0 for none.
///
/// G2: authored empty-slot background for the Buying tab's own item
/// strip (), or 0 for none.
///
///
/// G2: authored empty-slot background for the Selling tab's own item
/// strip (), or 0 for none.
///
public static VendorUiController? Bind(
ImportedLayout layout,
VendorState vendor,
RetailWindowHandle window,
Func resolveIcon,
ClientObjectTable objects,
Func playerGuid,
ItemInteractionController itemInteraction,
SelectionState selection,
StackSplitQuantityState splitQuantity,
UiDatFont? datFont,
BitmapFont? debugFont,
Func 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? 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 }
///
/// 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 — gmVendorUI::ListenToGlobalMessage (0x004c0480):
/// if (arg2 == 3) gmVendorUI::UpdateDragOver(this);. This
/// controller is not itself a (it wraps
/// several), so it cannot directly implement
/// the way does — '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 UIElementManager::UseTime delivers, without
/// adding a second per-frame plumbing path.
///
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;
}
}
///
/// Port of the tail of VendorItemsUI::OpenVendor
/// (pc:200789-201022): flush the menu, walk
/// in order keeping only entries
/// ListContainsType 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).
///
private void RebuildCategories()
{
IReadOnlyList 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();
}
///
/// User picked a different dropdown entry — port of
/// VendorItemsUI::UpdateItemsList's explicit-mask branch
/// (arg2 != 0, pc:201039-201040).
///
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();
}
///
/// Port of VendorItemsUI::UpdateItemsList's item-insertion loop
/// (pc:201059-201158): only items whose type intersects the
/// active mask are shown. There is no "all categories" state — an empty
/// (no vendor stock at all, or nothing
/// selected) shows zero rows, matching retail.
///
///
/// F4/F7a (Slice 5.4 review) + F6 (Slice 6 review): the tail of the SAME
/// retail function (pc:201180-201190) — after every rebuild the
/// list unconditionally scrolls back to its start
/// (ScrollToShow(m_shopList, 0)) and, on the retail caller's
/// "notify" paths, the FIRST item that passed the filter becomes the
/// selection UNCONDITIONALLY — there is no survival test
/// (pc:201180-201184: if (arg3 != 0) SetSelectedObject(i_1, 0),
/// where i_1 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
/// VendorItemsUI::OpenVendor UNCONDITIONALLY (pc:203852,
/// not gated on the sameVendor flag), which clamps the dropdown index
/// and calls UIElement_Menu::SetSelectedItem(..., 1)
/// (pc:200783-201022) — that trailing 1 is retail's
/// selection-changed notify flag, and setting the menu's selection
/// synchronously cascades into UpdateItemsList(0, 1) via
/// gmVendorUI::ListenToElementMessage's idMessage==7 case
/// (pc:204302-204303) — the SAME idMessage==7/notify=1 path a
/// manual category switch () already takes.
/// Retail's ONLY notify=0 (no-reselect) call site is a bare tab-page-open
/// with no category/vendor change (idMessage==0x2c,
/// m_OpenPageToken==0x100000bc, pc:204283-204285) — this
/// controller has no equivalent call site ( never
/// calls this method), so every path that reaches
/// is retail's notify=1 case. Slice 6.2:
/// retail routes the selection through the global
/// ACCWeenieObject::selectedID/SetSelectedObject
/// (pc:201184) — this calls /
/// instead of the retired private
/// field, so the toolbar status bar and slider light up for the
/// auto-selected item too.
///
private void RebuildItemList() => RebuildItemList(reselectFirst: true);
///
/// F2 (Slice 6b/6c review): for every ORIGINAL
/// call site (category rebuild — Opened/Refreshed/manual category
/// switch, retail's notify=1 case per the remarks above).
/// for the NEW staging-triggered repaint this
/// review added () — retail's
/// RemoveFromShop/DeleteItem (the function that actually
/// hides/shrinks a row as staging consumes it) never reselects to the
/// first item; only a full UpdateItemsList rebuild does that, and
/// staging a Buy does not trigger one.
///
private void RebuildItemList(bool reselectFirst)
{
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? _presentCategories[_selectedCategoryIndex].Mask
: default;
uint maskValue = (uint)activeMask;
IReadOnlyList 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);
// AP-171: double-click buys the item — a DELIBERATE,
// user-approved modernization. Retail has NO
// double-click-to-buy anywhere in the named function
// table (negative evidence recorded at the Slice 6
// research); the user requested it explicitly
// 2026-08-08 after being told so. Select-then-buy so
// the quantity/price path is identical to the Buy
// button's.
cell.DoubleClicked = () =>
{
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
BuySelectedItem();
};
_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);
}
}
///
/// F2 (Slice 6b/6c review): retail's RemoveFromShop
/// (0x004c3ce0) recomputes "shop remaining = ORIGINAL vendor
/// supply - the item's CURRENT total staged amount" fresh on every call
/// (pc:202844: _maxStackSize_1 = var_c - arg3, where
/// var_c is read fresh from the untouched shopItemProfileList
/// and arg3 is AddToBuyList's own running accumulated
/// total) rather than incrementally decrementing a mutable counter.
/// This mirrors that shape exactly:
/// (the untouched ApproachVendor snapshot) combined with
/// 's current total, recomputed on every call
/// — so un-staging automatically restores a hidden/shrunk row with no
/// separate "restore" code path. for
/// unlimited supply ( == -1,
/// retail's ItemProfile sign-extended unlimited sentinel) —
/// retail's own var_c != 0xffffffff guard skips the whole
/// decrement/delete branch for unlimited stock.
///
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;
}
///
/// F2 (Slice 6b/6c review): repaints the Items tab's row visibility
/// whenever changes, WITHOUT the
/// reselect-to-first behavior a full category rebuild performs — see
/// 's reselectFirst doc.
///
private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
///
/// Port of retail row selection display —
/// VendorItemsUI::UpdateItemsUI (0x004C38E0,
/// pc:202539-202820). Slice 6.2: called ONLY from
/// , once has
/// already been confirmed to be the globally-selected guid — this method
/// no longer writes the selection itself (
/// is the authority; row clicks, the F4 auto-select fallback, and
/// right-click examine all call
/// directly and let this method react).
///
///
/// F2/F3 (Slice 5.4 review), rewired for F2 (Slice 6 review): the
/// priced/named QUANTITY is retail's ItemHolder::GetObjectSplitSize
/// (0x00586F00, pc:401465-401477) read LIVE — retail
/// re-runs this SAME display update on every slider change
/// (gmVendorUI::RecvNotice_StackSliderChanged,
/// pc:203262-203278, 0x004C4500, wired via
/// below), reading the singular/
/// plural gate, the count, and the price off the CURRENT split value
/// (pc:202602/202621/202644), not a value frozen at
/// selection time. is the SAME
/// computation uses to decide what it
/// actually sends, so the displayed price always equals what a Buy press
/// would charge. VendorSplitPolicy.SeedQuantity stays only in
/// 's real seeding path (the
/// toolbar slider's INITIAL value); this display no longer reads it.
/// Name: ACCWeenieObject::GetObjectName (0x0058E6E0,
/// pc:409056-409132) — NAME_SINGULAR for quantity <= 1,
/// NAME_PLURAL for quantity > 1; when no plural is authored
/// (m_len==1, an empty PString) retail falls back to the
/// SINGULAR name UNCHANGED — read directly off GetObjectName's
/// body (label label_58e76b's m_buffer->m_len != 1
/// branch falls through to the plain _name read when false), NOT
/// the "likely name+s" guess. Cost text: .
/// Button state: no selection disables Buy/Add
/// (SetState(0xd), pc:202572-202577); a selection enables
/// them (SetState(1), pc:202784-202789) — modeled here as
/// , the project-wide idiom for this
/// retail state pair (see ToolbarController's
/// _useButton.Enabled).
///
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);
}
///
/// Right-click examine on a shop row — mirrors
/// ExternalContainerController.ExamineItem'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
/// —
/// applies the display update, so this method no longer needs to search
/// 's items itself.
///
private void ExamineItem(uint guid)
{
_selection.Select(guid, SelectionChangeSource.Vendor);
_itemInteraction.ExamineSelectedOrEnterMode(guid);
}
///
/// Slice 6.2: reacts to ANY global selection change, not just ones this
/// panel originated — mirrors ExternalContainerController.OnSelectionChanged.
/// Shows this panel's own price/name text for the newly-selected guid
/// when it is one of '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).
///
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();
}
/// See 's F9 note.
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;
}
}
}
///
/// F2 (Slice 6 review): retail's gmVendorUI::RecvNotice_StackSliderChanged
/// (pc:203262-203278, 0x004C4500) — a registered listener
/// on the SAME global slider-changed notice
/// broadcasts, gated (in retail)
/// on the panel being visible and the globally-selected item being one
/// of the vendor's own. already
/// applies that same gate (it no-ops to
/// when the current selection isn't a vendor item), so no separate
/// visibility check is needed here.
///
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();
}
///
/// Slice 6.2: retail's VendorItemsUI::RemoveFromShop
/// (pc:202848-202850, 0x004c3d4a) clears the global
/// selection when a shop item leaves the vendor's list — this mirrors
/// that (and, transitively, every other panel's own
/// OnObjectRemoved) rather than a vendor-specific "on close, set
/// selected to 0" special case. VendorShopItemMaterializer
/// 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."
///
///
/// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession
/// sites, both reachable through this SAME
/// notification in this architecture.
///
/// Sell side — gmVendorUI::RecvNotice_ServerSaysMoveItem
/// (0x004c44a0): a staged SELL item silently unstages once
/// ACCWeenieObject::IsOwnedByPlayer goes false for it (no notice
/// shown). The closest reachable proxy here is "the item left
/// 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.
///
///
/// Buy side — VendorItemsUI's shop-list-removal notice
/// (0x004c4246, inside gmVendorUI::HandleMousePresses,
/// pc:203165: "Removing %s from shopping list"). 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 by
/// VendorShopItemMaterializer'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.
///
///
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");
}
}
///
/// F2/F7 (Slice 6 review): the SINGLE quantity computation both the
/// display () and the actual purchase
/// () use — retail's
/// gmVendorUI::BuySingleItem (pc:201661,
/// 0x004C2820): quantity 1 when the item's own authored stack
/// size is <= 1 (pc:201674-201681) — 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
/// ItemHolder::GetObjectSplitSize (0x00586F00).
///
/// 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 —
/// FIRST (retail's own
/// vendor-owned quantity operand: the Buy cases at
/// pc:203996/204086 gate on pwd._maxStackSize > 1
/// literally), falling back to
/// — the SAME resolution uses
/// to seed (and therefore the
/// toolbar slider's own ceiling, SelectedObjectController).
/// 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.
///
///
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);
}
///
/// Cost sentence — VendorItemsUI::UpdateItemsUI's tail
/// (pc:202641-202790). No-alt-currency: verb agreement ("costs"
/// for quantity <= 1, "cost" for > 1 — pc:202759-202762),
/// comma-grouped price/holdings (retail's InsertCommas, ported as
/// ToString("N0") with )
/// via the literal format "%s %hsp (you have %hsp)"
/// (pc:202769). Alt-currency: the FULL retail format string, read
/// directly off the data segment rather than the truncated call-site
/// view — "This item costs %d %s. You have %d %s."
/// (acclient_2013_pseudo_c.txt:991314) — this branch uses RAW
/// (not comma-grouped) integers, an asymmetry retail's own literal
/// format establishes, not an oversight here.
///
/// The player's primary-currency holding is retail's
/// gmVendorUI::m_totalValue, read from PropertyInt.CoinValue
/// (pc:203494-203497) via the SAME
/// generic int-property bundle every other PropertyInt-driven display
/// reads. The alt-currency holding is retail's
/// shopVendorProfile->trade_num - m_last_sale;
/// m_last_sale only changes on a completed Slice-6 purchase, so
/// with no purchase mechanism yet this port uses
/// directly
/// (retail's m_last_sale == 0 case — see the register, AP-161).
///
///
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);
}
///
/// 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.
///
private void SetActionButtonsEnabled(bool enabled)
{
_buyEnabledBySelection = enabled;
RecomputeBuyButtonEnabled();
}
///
/// Slice 6.3/6b: the Buy AND Add buttons' enabled state is the
/// CONJUNCTION of "is something selected" (,
/// set by ) and "is the shared
/// inventory/use gate free right now"
/// ().
/// Add is selection-gated only (retail's AddToBuyList 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
/// tick, so Buy disables the instant
/// takes its reservation and re-enables on the matching completion — no
/// per-frame polling.
///
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();
///
/// Slice 6.3: retail gmVendorUI::BuySingleItem (pc:201661).
/// Reads the CURRENT globally-selected shop item and dispatches a
/// single-item purchase through the shared use/inventory reservation,
/// using — the SAME quantity
/// computation 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).
///
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;
}
///
/// Slice 6b: Items-tab "Add to List" — gmVendorUI::HandleButtonClicks
/// case 0x100000c3 (pc:203970-203988). Stages the
/// globally-selected shop item at — the
/// SAME quantity computation the display and Buy button already share
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
/// (VendorItemsUI::AddToBuyList 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 and
/// leaves the entry unchanged — see .
///
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);
}
///
/// Retail's Buy Item/Clear Item (Buying tab) shared
/// removal-amount rule (pc:203996/204086): stackable -> -1
/// (full removal), else 1. Retail tests the item's own
/// pwd._maxStackSize (the item TYPE's stack ceiling) — now a
/// byte-exact port (register AP-165 RETIRED 2026-08-08, R1 gate
/// finding): threads the wire
/// field through directly, so this no longer needs the
/// substitute the row
/// originally filed.
///
private static int BuyStagingRemovalAmount(VendorShopItem item) =>
(item.MaxStackSize ?? 1) > 1 ? -1 : 1;
///
/// Slice 6b: "Buying" tab's "Buy Item" — retail case 0x100000c9
/// (pc:203989-204010). Reuses the SAME immediate single-item
/// BuySingleItem path ()
/// 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 ().
///
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));
}
}
///
/// Retail's exact affordability-failure notice — read from the
/// decompiled binary's data segment at 0x007b57b4 (BuyAllButtonPressed's
/// two guards below share this SAME string; see the class doc).
///
private const string NotEnoughMoneyMessage = "You don't have enough money";
///
/// Retail's exact capacity-failure notice — read from the decompiled
/// binary's data segment at 0x007b5750 (BuyAllButtonPressed's
/// two capacity guards below share this SAME string; see the class doc).
///
private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first";
///
/// Slice 6b: "Buying" tab's "Buy All" — retail case 0x100000ca
/// (pc:204011-204079, 0x004c5388). Sends every staged
/// entry as ONE batched Buy ().
///
/// 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):
///
///
/// - pyreal affordability — transaction total vs. purse
/// (pc:204017: m_transactionValue <= m_totalValue).
/// - alt-currency affordability — vs. held trade currency minus
/// m_last_sale (pc:204032). This session tracks no
/// m_last_sale credit yet (see the register's AP-161 residual),
/// so this uses the vendor's raw held count, retail's own
/// m_last_sale == 0 case.
/// - container-slot capacity (pc:204053:
/// containerSlotsNeeded > player.ContainersCapacity - containersUsed).
/// - item-slot capacity (pc:204067: the same shape for
/// item slots).
///
/// Both affordability guards share ;
/// both capacity guards share —
/// retail's own two distinct StringInfo literal sites collapse
/// to exactly these two strings (pc:204020/204034 both
/// reference 0x007b57b4; pc:204056/204068 both
/// reference 0x007b5750 via the shared label_4c5509).
///
/// Container-vs-item slot classification (register AP-168, narrowed
/// G1 gate-finding fix 2026-08-08). Retail's own split tests a
/// bitfield bit this codebase does not currently thread onto
/// (gmVendorUI::InqListSlotCount,
/// pc:200038-200065) — this port approximates "is this shop item
/// a container" with 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-Container-typed item that still authors nonzero pack/side
/// capacities. This residual applies ONLY to the shop-stock side
/// () —
/// genuinely has no wire-carried classification field to read instead.
/// The player's-OWN-pack side () no
/// longer shares this approximation: it now reads
/// (retail's actual wire
/// ContainerProperties, already threaded onto every owned object)
/// first, matching retail's real _itemsList/_containersList
/// bucketing exactly for anything that ever received a hint. Live
/// testing showed the OLD dual-heuristic (also checking nonzero
/// ItemsCapacity/ContainersCapacity) could over-classify a
/// non-container object as an occupied container slot and false-block a
/// purchase with real free slots — see the register.
///
/// On a successful DISPATCH the whole staged list is flushed
/// UNCONDITIONALLY and immediately, matching retail's literal order:
/// SendShopEvent(...) then
/// PackableList::Flush(&m_buyList) (pc:204075-204076) —
/// the clear happens right after the send, not gated on the eventual
/// server response/UseDone.
///
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();
}
/// F1: the SAME per-row price formula shows, summed over every staged entry.
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;
}
///
/// R3: the Selling tab's total-proceeds counterpart to
/// — retail
/// VendorSellUI::UpdateTransactionValue (pc:202380-202468),
/// which prices each staged row via VendorProfile::VendorBuyPrice
/// (the rate the VENDOR pays when IT buys FROM the player — see
/// 's naming-inversion warning). Unlike the
/// Buying side (whose priced item lives in 's shop
/// list), a staged SELL entry's item is the PLAYER's OWN pack item —
/// , the same source
/// already reads for icon/type data.
///
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;
}
///
/// 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
/// VendorSellUI::UpdateTransactionValue (pc:202458,
/// u"Selling %d %s worth %hsp"); the Buy side's IDENTICALLY-SHAPED
/// literal is mis-attributed by the decompiler to a bogus vtable-slot
/// symbol at its own call site (VendorBuyUI::UpdateTransactionValue,
/// pc:202290) — recovered instead by reading the binary's own
/// data segment directly (C:\Users\erikn\Downloads\acclient.exe,
/// the Sept 2013 EoR build paired with refs/acclient.pdb), which
/// carries the wide string "Buying %d %s worth %hsp" at VA
/// 0x007b58bc (Sell's own literal likewise resolves at
/// 0x007b5930, confirming both strings byte-for-byte). The "p"
/// after %hs is a LITERAL pyreal-currency suffix character —
/// same convention as 's own
/// "{0} {1}p (you have {2}p)" — 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
/// _stackSize accumulator feeding the same singular/plural test.
///
/// Alt-currency (a rare trade-note vendor): the Buy side's exact literal
/// IS confirmed directly legible in the decompiled body
/// (VendorBuyUI::UpdateTotalValue, pc:202344,
/// "You have %d %s.") 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.
///
///
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));
}
///
/// R3: shared "You have %hsp" purse-text builder — retail
/// VendorBuyUI::UpdateTotalValue (pc:202366,
/// u"You have %hsp") and VendorSellUI::UpdateTotalValue
/// (pc:202495, the SAME literal u"You have %hsp") — both
/// directly legible in the decompiled body, byte-identical. Comma
/// grouping matches retail's own InsertCommas call immediately
/// before this format runs. The alt-currency branch's literal
/// "You have %d %s." is directly legible at
/// VendorBuyUI::UpdateTotalValue (pc:202344); the Sell
/// side's alt-currency purse text was not independently traced but is
/// presumed identical by symmetry — both read the SAME
/// shopVendorProfile->trade_num - m_last_sale holding
/// already
/// substitutes for elsewhere (see the register, AP-161's m_last_sale
/// residual, and AP-166 for this untraced half).
///
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));
}
///
/// R3: repaints the Buying tab's own summary text — retail
/// VendorBuyUI::Update (pc:202996-203005) calls
/// UpdateTransactionValue then UpdateTotalValue
/// unconditionally on every staging mutation.
///
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));
}
///
/// R3: the Selling tab's counterpart to —
/// retail VendorSellUI::Update (pc:203009-203018), same shape.
///
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));
}
///
/// 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
/// 's own PropertyInt.CoinValue read),
/// with no separate "did staging change" gate.
///
private void OnObjectMoneyChanged(ClientObject updated)
{
if (updated.ObjectId != _playerGuid())
return;
UpdateBuyTransactionText();
UpdateSellTransactionText();
}
///
/// F1: port of gmVendorUI::InqListSlotCount (pc:200038-200065,
/// 0x004c0c10) — see 's own doc
/// comment for the container-classification approximation.
///
/// R1 gate-finding fix (2026-08-08): retail's stackable test at this
/// exact call site is eax->pwd._maxStackSize <= 1
/// (pc:200052) — literally MaxStackSize, never
/// _stackSize. This now reads
/// directly, a byte-exact port now that the wire field is threaded
/// through (previously approximated with DescStackSize, 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).
///
///
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);
}
///
/// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied
/// item/container slot counts. Retail's own GetNumContainedItems/
/// GetNumContainedContainers (0x0058beb0/0x0058bec0)
/// don't reclassify anything at count time — they just report the length
/// of two ALREADY-BUCKETED IDLists (_itemsList/
/// _containersList). The bucketing happens once, at INSERT time
/// (ACCWeenieObject::ServerSaysContainID @ 0x0058be40), from the
/// wire's own ContainerProperties field (Item_ServerSaysContainId
/// 0x0022's ContainerType; also carried by ContentProfile /
/// PlayerDescription'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.
/// is exactly that wire
/// field, already threaded through every membership path
/// (InitializeInventoryManifest, InventoryPutObjInContainer,
/// ViewContents) and already used for this identical
/// container-vs-item question elsewhere
/// (ClientObjectTable.IsContainerListMember). This port previously
/// used ONLY the local /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 's
/// single signal) for the rare object that reached the table without
/// ever threading a hint.
///
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);
}
///
/// Slice 6b: "Buying" tab's "Clear Item" — retail case 0x100000cb
/// (pc:204080-204094). The EXACT SAME removal call "Buy Item"
/// makes (label_4c52ea) but WITHOUT buying first — pure removal,
/// no transaction.
///
private void BuyClearItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid)
return;
int amount = TryFindShopItem(guid, out VendorShopItem shopItem)
? BuyStagingRemovalAmount(shopItem)
: -1;
_buyStaging.Remove(guid, amount);
}
///
/// Retail's exact refusal for a partial-stack Sell Item attempt,
/// read from the decompiled binary's data segment at
/// 0x007b57ec (gmVendorUI::SellSingleItem,
/// pc:201860-201864).
///
private const string CannotSellPartialStackMessage = "Cannot sell part of a stack";
///
/// Slice 6c: "Selling" tab's "Sell Item" — retail case 0x100000d2
/// (pc:204101-204112), calling gmVendorUI::SellSingleItem
/// (pc:201808-201881, 0x004c2b40).
///
/// F13 (Slice 6b/6c review): retail reads ACCWeenieObject::selectedID
/// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid
/// actually staged" requirement at all. A prior version of this port
/// required a matching 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).
///
///
/// F6 (Slice 6b/6c review, byte-verified): SellSingleItem
/// 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 and sends NOTHING
/// (pc:201833-201864: _stackSize<=1 || splitSize>=maxSplitSize
/// gates the send). On success it sends amount 1 LITERALLY
/// (var_9c = 1, pc:201838) — not the stack size — matching
/// retail's own send exactly rather than .
/// This method does NOT re-run 's
/// ownership/type/value gate — retail's own SellSingleItem
/// doesn't either at this call site (that gate is drag-time only,
/// VendorSellUI::DragItemAcceptable); the server remains
/// authoritative for a selection that was never legitimately
/// draggable.
///
///
/// Retail's own non-empty-container refusal branch inside
/// SellSingleItem (pc:201818-201829, 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.
///
/// On a successful dispatch the item's own staged entry (if any) is
/// still removed unconditionally, mirroring retail's own
/// RemoveProfileFromList(&m_sellList, selectedID, 0xffffffff)
/// (pc:204108), which runs regardless of whether the sold item
/// was ever actually staged.
///
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);
}
///
/// Slice 6c: "Selling" tab's "Sell All" — retail case 0x100000d3
/// (pc:204113-204129). Sends every staged entry as ONE batched
/// Sell. On a successful dispatch the whole staged list is flushed
/// unconditionally and immediately (the shared tail label_4c560a
/// also reached by "Clear List" — FlushSellListSellState +
/// PackableList::Flush(&m_sellList)), the same optimistic
/// clear-right-after-send shape as .
///
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();
}
///
/// Slice 6c: "Selling" tab's "Clear Item" — retail case 0x100000d4
/// (pc:204131-204137): always a full removal (0xffffffff),
/// no transaction. Retail also clears the item's "pending sell"
/// highlight in the player's own inventory panel
/// (VendorItemSetSellState) — that cross-panel highlight is a
/// deliberately unported cosmetic (see the register).
///
private void SellClearItemButtonPressed()
{
if (_selection.SelectedObjectId is not { } guid)
return;
_sellStaging.Remove(guid, -1);
}
///
/// Renders into the "Buying" tab's own item
/// strip — icon cells identical in shape to the Items list's own (the
/// SAME 0x1000033A cell-template prototype, per the G2 vendor-gate
/// finding), selectable so /
/// can read the globally-selected
/// guid. A staged guid that has left 's current shop
/// list (a rare race, e.g. mid-refresh) is skipped rather than crashing.
///
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);
}
}
}
///
/// Renders into the "Selling" tab's own item
/// strip. Unlike , a staged sell entry's
/// icon/type data comes from (the PLAYER's own
/// pack item), not 's shop list — the item was
/// dragged FROM the player's inventory, never authored as vendor stock.
///
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) ──────────────────────────────────────────────
///
/// The Selling list never sources a drag of its own — every staged cell
/// sets AllowDragSource = false (F3, Slice 6 review), the same
/// non-drag-source convention every vendor row uses — so
/// '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.
///
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;
}
///
/// F5 (Slice 6b/6c review): port of gmVendorUI::UpdateDragOver
/// (0x004c03b0, pc:199542-199553), driven by
/// '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
/// UIElementManager::s_pInstance->m_dragElement != 0, here
/// ), retail opens the Selling tab the
/// instant the pointer enters the WINDOW'S bounds — not any specific
/// list — making a reachable drop target
/// without the player manually clicking the tab first.
///
/// Citation correction. Retail's own guard token is
/// m_OpenPageToken != 0x100000cd — the review that flagged this
/// finding described 0x100000cd as "the Buying page," but this
/// controller's own constant is
/// 0x100000CD, not
/// (0x100000C4). The guard is "don't reopen the tab you're
/// already on," checked against the SELLING page specifically —
/// 's own Visible flag is the exact
/// analogue.
///
///
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);
}
///
/// Port of VendorSellUI::AcceptDragObject (pc:203866-203905,
/// the non-silent release path that calls DragItemAcceptable with
/// silent=0, showing a rejection string) chained into
/// VendorSellUI::AddItemToSell (pc:203546-203567) 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".
///
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);
}
///
/// Shared drag-over/drop gate —
/// fed from the dragged item's own state and
/// the open vendor's merchandise terms.
/// is the staged quantity a successful drop
/// would use.
///
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
/// FULL current stack — retail's VendorSellUI::AddItemToSell
/// (pc:203546-203567) stages via gmVendorUI::AddItem(...,
/// itemGuid, -1, ...), a LITERAL -1 "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 AddToBuyList) — that
/// inference is now known WRONG: Sell staging has no partial-quantity
/// feature in retail at all, unlike Buy. See
/// VendorStagingList.Add's own doc comment for the Buy side's
/// (genuinely slider-driven) contrast.
///
///
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;
}
///
/// G4/Slice 6b: port of retail's close/pushpin button handler —
/// gmVendorUI::HandleButtonClicks's 0x100000d6 case
/// (pc:204147-204182). Retail branches on whether the
/// Buying/Selling staging lists (m_buyList/m_sellList) hold
/// anything uncommitted: with nothing pending it calls ONLY
/// this->vtable->SetVisible(0) — a plain window hide, NOT
/// gmVendorUI::CloseVendor (pc:202080, the range-watcher-
/// unregister/session-teardown function
/// ports). Only when something IS pending, and only when no confirmation
/// is already up (gmVendorUI::m_curDialogContext == 0,
/// pc:204155), 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 0x007b5bd8, resolving the Slice 6b/6c research doc's
/// truncated "…" citation). Its Yes callback
/// (gmVendorUI::CloseVendorDialogCallback, pc:202104-202166)
/// reaches CM_Vendor::SendNotice_CloseVendor — an internal
/// notice-bus fanout, not a network send (class doc's A.4 citation) —
/// and nothing in that call chain touches m_buyList/m_sellList,
/// 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.
///
///
/// Behavior change from the prior port. This button used to call
/// 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:
/// is evaluated
/// unconditionally every frame regardless of window visibility (it reads
/// only , never this window's
/// IsVisible), so leaving UseRadius still converges to a
/// full exactly as before. Re-approaching
/// the SAME vendor before then now lands on retail's sameVendor==1
/// refresh-in-place path (,
/// which preserves the player's category selection) instead of a full
/// from-scratch reopen.
///
///
/// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's
/// declared string length (0x53 wchar16) truncates the literal
/// mid-sentence, but the raw bytes immediately following it
/// (0x007b5c7e) are 3f 00 — UTF-16LE for '?' —
/// before the null terminator. Retail's data segment carries a trailing
/// question mark this port previously dropped.
///
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();
});
}
///
/// F8 (Slice 6b/6c review): shared by and the
/// session Closed/Reset arm of — a live
/// X-close confirmation dialog must not outlive the controller or the
/// vendor session it was asking about.
///
private void DismissCloseConfirmationIfOpen()
{
if (_closeConfirmContext == 0u)
return;
_dialogs?.CloseDialog(_closeConfirmContext);
_closeConfirmContext = 0u;
}
private void ClearContent()
{
_presentCategories.Clear();
_selectedCategoryIndex = -1;
_typeMenu.Items = Array.Empty();
_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();
}
///
/// G2 (vendor gate finding): mirrors the Items strip's empty-slot-fill
/// configuration (see the constructor's _itemList block) for the
/// Buying/Selling pages' own authored lists — presentation only. The
/// list is flushed once (dropping the single default cell every
/// constructs itself with) so every visible
/// cell comes from 's own
/// with consistent styling
/// (non-drag-source), then left alone: nothing ever calls
/// on it, so
/// 's empty-slot padding
/// (UpdateEmptySlots) is the ONLY thing that ever populates it.
///
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 lines = string.IsNullOrEmpty(value)
? Array.Empty()
: 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;
}
}