diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs
index f3b341d6..7e7f9399 100644
--- a/src/AcDream.App/UI/Layout/VendorUiController.cs
+++ b/src/AcDream.App/UI/Layout/VendorUiController.cs
@@ -48,10 +48,16 @@ namespace AcDream.App.UI.Layout;
///
/// Lifecycle: pure state projection. This controller owns no
/// session/transaction state of its own. It opens/repopulates/closes purely
-/// by observing ; the one mutation it may
-/// trigger is the close button calling —
-/// going through the owner (client-local per research §A.4, nothing on the
-/// wire), never writing fields directly.
+/// 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
@@ -79,6 +85,19 @@ public sealed class VendorUiController : IRetainedPanelController
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;
+
///
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
/// Retail UIElement_Menu::MakePopup (0x0046D310,
@@ -124,6 +143,35 @@ public sealed class VendorUiController : IRetainedPanelController
/// 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
@@ -150,6 +198,19 @@ public sealed class VendorUiController : IRetainedPanelController
private const uint TypeMenuNormalSprite = 0x060012B3u;
private const uint TypeMenuPressedSprite = 0x060012B4u;
+ // 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
@@ -195,6 +256,10 @@ public sealed class VendorUiController : IRetainedPanelController
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;
@@ -228,6 +293,10 @@ public sealed class VendorUiController : IRetainedPanelController
UiElement sellingTab,
UiItemList itemList,
UiScrollbar? itemScrollbar,
+ UiItemList? buyingList,
+ UiScrollbar? buyingScrollbar,
+ UiItemList? sellingList,
+ UiScrollbar? sellingScrollbar,
UiMenu typeMenu,
UiText itemNameText,
UiText itemCostText,
@@ -237,7 +306,9 @@ public sealed class VendorUiController : IRetainedPanelController
UiDatFont? datFont,
BitmapFont? debugFont,
Func resolveSprite,
- uint emptySlotSprite)
+ uint emptySlotSprite,
+ uint buyingEmptySlotSprite,
+ uint sellingEmptySlotSprite)
{
_vendor = vendor;
_window = window;
@@ -254,6 +325,8 @@ public sealed class VendorUiController : IRetainedPanelController
_buyingTab = buyingTab;
_sellingTab = sellingTab;
_itemList = itemList;
+ _buyingList = buyingList;
+ _sellingList = sellingList;
_typeMenu = typeMenu;
_itemNameText = itemNameText;
_itemCostText = itemCostText;
@@ -295,6 +368,25 @@ public sealed class VendorUiController : IRetainedPanelController
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;
+ }
+
// 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
@@ -309,6 +401,18 @@ public sealed class VendorUiController : IRetainedPanelController
_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;
_typeMenu.OnSelect = payload =>
{
if (payload is uint mask) SelectCategory(mask);
@@ -322,7 +426,7 @@ public sealed class VendorUiController : IRetainedPanelController
RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying));
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
if (_close is not null)
- _close.OnClick = () => _vendor.Close();
+ _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).
@@ -398,6 +502,14 @@ public sealed class VendorUiController : IRetainedPanelController
/// 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,
@@ -411,7 +523,9 @@ public sealed class VendorUiController : IRetainedPanelController
UiDatFont? datFont,
BitmapFont? debugFont,
Func resolveSprite,
- uint emptySlotSprite = 0u)
+ uint emptySlotSprite = 0u,
+ uint buyingEmptySlotSprite = 0u,
+ uint sellingEmptySlotSprite = 0u)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(vendor);
@@ -442,6 +556,12 @@ public sealed class VendorUiController : IRetainedPanelController
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;
return new VendorUiController(
vendor,
@@ -460,6 +580,10 @@ public sealed class VendorUiController : IRetainedPanelController
sellingTab,
itemList,
itemScrollbar,
+ buyingList,
+ buyingScrollbar,
+ sellingList,
+ sellingScrollbar,
typeMenu,
itemNameText,
itemCostText,
@@ -469,7 +593,9 @@ public sealed class VendorUiController : IRetainedPanelController
datFont,
debugFont,
resolveSprite,
- emptySlotSprite);
+ emptySlotSprite,
+ buyingEmptySlotSprite,
+ sellingEmptySlotSprite);
}
private enum VendorPanelTab { Items, Buying, Selling }
@@ -906,9 +1032,14 @@ public sealed class VendorUiController : IRetainedPanelController
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} (you have {2})",
+ "{0} {1}p (you have {2}p)",
verb,
price.ToString("N0", CultureInfo.InvariantCulture),
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
@@ -1002,6 +1133,52 @@ public sealed class VendorUiController : IRetainedPanelController
_vendor.Profile.AlternateCurrencyWcid);
}
+ ///
+ /// G4 (vendor gate finding): 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 does retail show a
+ /// confirmation dialog ("You have not completed all transactions...")
+ /// whose Yes callback (gmVendorUI::CloseVendorDialogCallback,
+ /// pc:202104-202166) is what actually reaches
+ /// CM_Vendor::SendNotice_CloseVendor — itself an internal
+ /// notice-bus fanout to local UI listeners, not a network send (see the
+ /// class doc's A.4 citation: retail's close path never puts anything on
+ /// the wire either way).
+ ///
+ /// This controller's staging lists are ALWAYS empty (Slice 6 territory —
+ /// the "Buying"/"Selling" tabs render but are never populated, see the
+ /// class doc's "Three tabs, not two" note), so retail's
+ /// m_buyList.head == 0 && m_sellList.head == 0 condition
+ /// is vacuously true for every close today — the confirmation-dialog
+ /// branch has no reachable case yet and is deliberately not ported;
+ /// revisit once staging lands.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ private void CloseButtonPressed() => _window.Hide();
+
private void ClearContent()
{
_presentCategories.Clear();
@@ -1018,6 +1195,39 @@ public sealed class VendorUiController : IRetainedPanelController
ClearSelectionDisplay();
}
+ ///
+ /// 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)
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 84c3a728..fa1ce966 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -1943,6 +1943,8 @@ public sealed class RetailUiRuntime : IDisposable
{
ImportedLayout? layout;
uint emptySlotSprite;
+ uint buyingEmptySlotSprite;
+ uint sellingEmptySlotSprite;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@@ -1959,6 +1961,18 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.ItemListId);
+ // G2 (vendor gate finding): the Buying/Selling pages author
+ // their OWN item strips (same cell-template prototype as the
+ // Items list per the fixture, but resolved independently here
+ // rather than assumed identical).
+ buyingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
+ _bindings.Assets.Dats,
+ VendorUiController.LayoutId,
+ VendorUiController.BuyingListId);
+ sellingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
+ _bindings.Assets.Dats,
+ VendorUiController.LayoutId,
+ VendorUiController.SellingListId);
}
if (layout is null)
{
@@ -2013,7 +2027,9 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite,
- emptySlotSprite);
+ emptySlotSprite,
+ buyingEmptySlotSprite,
+ sellingEmptySlotSprite);
if (VendorController is null)
{
Console.WriteLine("[M4] vendor: required authored controls are missing.");
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index d99f9869..a63a6766 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -202,6 +202,22 @@ public class UiItemSlot : UiElement
/// vendor rows regardless of ItemId.
public override bool IsDragSource => ItemId != 0 && AllowDragSource;
+ ///
+ /// G3 fix (vendor gate finding): a slot CAPTURES its own press/click the instant it
+ /// is occupied, independently of whether it is ALSO a drag source. Before this,
+ /// 's mousedown dispatch only claimed the press via
+ /// (checked first) — for a drag-allowed cell that's fine,
+ /// but the F3 fix that introduced made vendor/salvage
+ /// rows occupied-yet-IsDragSource == false, so they fell all the way through
+ /// UiRoot's chain to the window-move fallback: hovering showed the move-window
+ /// cursor ( also reads this flag) and a press
+ /// dragged the whole panel instead of selecting the row. Setting this true for any
+ /// occupied cell restores the capture without reopening the drag-source gate:
+ /// checks BEFORE
+ /// , so drag-allowed occupied cells are
+ /// unaffected — this only changes the occupied-but-non-drag-source case.
+ public override bool HandlesClick => ItemId != 0;
+
/// Walk up to the containing (the drop handler owner).
protected UiItemList? FindList()
{
diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs
index c10bd419..044b967f 100644
--- a/src/AcDream.App/UI/UiMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -34,10 +34,70 @@ public sealed class UiMenu : UiElement
/// Button-face caption (the active target). Null ⇒ blank face.
public Func? ButtonLabelProvider { get; set; }
- public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
+ public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
+ // ALSO the visible-row window height when Scrollable
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
+ ///
+ /// G5 (vendor gate finding): retail's authored vendor category popup
+ /// (LayoutDesc 0x21000043, root 0x1000034F) pairs its
+ /// ListBox (element 0x10000350, type 0x5) with a SIBLING
+ /// UIElement_Scrollbar (element 0x10000351, type 0xB,
+ /// 16px wide, docked immediately right of the list at x=100) — verified
+ /// via a live-dat scan (tools/VendorLayoutScan) against
+ /// client_local_English.dat: the ListBox reads a single-column
+ /// shape (attributes resolving to m_nCols=1/m_nRows=6) and
+ /// the row template (0x10000352) is 100×18 — a SCROLLABLE single
+ /// column with 6 visible rows, not our earlier column-major grid
+ /// approximation (which showed all 18 categories at once across 3
+ /// columns, never matching the retail screenshot's ~one-column-with-
+ /// scrollbar look). becomes the VISIBLE ROW
+ /// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
+ /// row height = 6). Chat's own popup (LayoutDesc 0x21000006) has
+ /// NO sibling scrollbar element and keeps the class default false — the
+ /// legacy column-major grid path below is untouched for it.
+ ///
+ public bool Scrollable { get; set; }
+
+ ///
+ /// Vertical scroll model for the popup when is
+ /// set. Content/view/line extents are (re)computed every draw from
+ /// .Count / /
+ /// , mirroring how
+ /// configures its own Scroll before every layout pass. Exposed so
+ /// a controller/test can assert or drive scroll position directly (the
+ /// popup owns no separate live CHILD widget —
+ /// see the scrollbar chrome properties below for why).
+ ///
+ public UiScrollable PopupScroll { get; } = new();
+
+ ///
+ /// Authored width of the popup's docked scrollbar (16px, element
+ /// 0x10000351's own Width).
+ ///
+ public float ScrollbarWidth { get; set; } = 16f;
+ /// Authored extent of the up/down buttons along the scrollbar's
+ /// own axis (16px, elements 0x10000071/0x10000072's own Height —
+ /// same convention as ).
+ public float ScrollButtonExtent { get; set; } = 16f;
+
+ // Scrollbar chrome sprites. UiMenu draws these itself (rather than hosting a
+ // live UiScrollbar child) because the popup renders in the OVERLAY pass (see
+ // OnDrawOverlay's doc comment) — a normal child widget would draw in the
+ // regular main pass and suffer the exact translucent-sibling artifact that
+ // pass exists to avoid. The geometry math is shared with UiScrollbar via its
+ // public static ThumbRect helper, so both draw identical thumbs.
+ public uint ScrollTrackSprite { get; set; }
+ public uint ScrollThumbSprite { get; set; }
+ public uint ScrollThumbTopSprite { get; set; }
+ public uint ScrollThumbBottomSprite { get; set; }
+ public uint ScrollUpSprite { get; set; }
+ public uint ScrollDownSprite { get; set; }
+
+ private bool _draggingPopupThumb;
+ private float _popupThumbDragOffset;
+
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
// square; the label starts just past it (box width + small gap) so text aligns with
@@ -72,8 +132,14 @@ public sealed class UiMenu : UiElement
private bool _open;
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
- private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
- private float InteriorW => ColumnCount * ColumnWidth;
+ // Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
+ // not a wrap threshold), widened by the docked scrollbar's own authored width.
+ private int ColumnCount => Scrollable
+ ? 1
+ : (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
+ private float InteriorW => Scrollable
+ ? ColumnWidth + ScrollbarWidth
+ : ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
@@ -130,41 +196,142 @@ public sealed class UiMenu : UiElement
var resolve = SpriteResolve;
if (!_open || resolve is null) return;
- // Column-major popup opening UPWARD from the button, wrapped in the universal
- // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a
- // bevelled floating window). Force OPAQUE (a menu reads solid even though the
- // chat window is translucent). Draw bevel → panel fill → row sprites → labels,
- // all through the sprite bucket in submission order so labels land on top.
+ // Force OPAQUE (a menu reads solid even though the chat window is translucent).
+ // Draw bevel → panel fill → row sprites → labels, all through the sprite bucket
+ // in submission order so labels land on top.
ctx.PushAlphaAbsolute(1f);
try
{
- float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
- float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
-
- DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
- DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
-
- for (int i = 0; i < Items.Count; i++)
- {
- int col = i / RowsPerColumn, row = i % RowsPerColumn;
- float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
- bool selected = Equals(Items[i].Payload, Selected);
- DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
- }
-
- float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
- for (int i = 0; i < Items.Count; i++)
- {
- int col = i / RowsPerColumn, row = i % RowsPerColumn;
- // Items grey out when unavailable; when EnabledProvider is null all items are enabled.
- bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
- DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
- avail ? TextColorAvailable : TextColorGhosted);
- }
+ if (Scrollable)
+ DrawScrollablePopup(ctx, resolve);
+ else
+ DrawGridPopup(ctx, resolve);
}
finally { ctx.PopAlpha(); }
}
+ /// Legacy column-major popup (chat's own shape — no authored sibling
+ /// scrollbar element; see 's doc comment). Unchanged from
+ /// before G5.
+ private void DrawGridPopup(UiRenderContext ctx, Func resolve)
+ {
+ float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
+ float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
+
+ DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
+ DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
+
+ for (int i = 0; i < Items.Count; i++)
+ {
+ int col = i / RowsPerColumn, row = i % RowsPerColumn;
+ float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
+ bool selected = Equals(Items[i].Payload, Selected);
+ DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
+ }
+
+ float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
+ for (int i = 0; i < Items.Count; i++)
+ {
+ int col = i / RowsPerColumn, row = i % RowsPerColumn;
+ // Items grey out when unavailable; when EnabledProvider is null all items are enabled.
+ bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
+ DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
+ avail ? TextColorAvailable : TextColorGhosted);
+ }
+ }
+
+ ///
+ /// G5: single-column popup with a docked scrollbar — port of the vendor category
+ /// dropdown's authored shape (LayoutDesc 0x21000043, see 's
+ /// doc comment). Draws exactly rows (the authored visible
+ /// window), sliced from starting at , plus
+ /// the scrollbar chrome using the SAME thumb geometry itself
+ /// uses ().
+ ///
+ private void DrawScrollablePopup(UiRenderContext ctx, Func resolve)
+ {
+ ConfigurePopupScroll();
+
+ float outerTop = -OuterH;
+ float inX = Border, inY = outerTop + Border;
+
+ DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
+ DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
+
+ int start = VisibleTopRow;
+ int count = System.Math.Min(RowsPerColumn, Items.Count - start);
+ float textY = (RowHeight - LineH()) * 0.5f;
+ for (int i = 0; i < count; i++)
+ {
+ int idx = start + i;
+ float y = inY + i * RowHeight;
+ bool selected = Equals(Items[idx].Payload, Selected);
+ DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, inX, y, ColumnWidth, RowHeight);
+ }
+ for (int i = 0; i < count; i++)
+ {
+ int idx = start + i;
+ float y = inY + i * RowHeight;
+ bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
+ DrawLabel(ctx, Items[idx].Label, inX + TextIndent, y + textY,
+ avail ? TextColorAvailable : TextColorGhosted);
+ }
+
+ DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
+ }
+
+ /// Recomputes 's extents from the current item
+ /// count/geometry — mirrors 's own "configure the
+ /// shared scroll model right before using it" pattern.
+ private void ConfigurePopupScroll()
+ {
+ int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
+ PopupScroll.LineHeight = lineHeight;
+ PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight);
+ }
+
+ /// Index of the first visible row — nearest-row snap of the (possibly
+ /// mid-drag, pixel-continuous) scroll offset, so drawn rows never render partially
+ /// clipped.
+ private int VisibleTopRow
+ {
+ get
+ {
+ int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
+ int maxStart = System.Math.Max(0, Items.Count - RowsPerColumn);
+ int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight);
+ return System.Math.Clamp(row, 0, maxStart);
+ }
+ }
+
+ private void DrawPopupScrollbar(
+ UiRenderContext ctx, Func resolve, float x, float y)
+ {
+ DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH);
+
+ float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
+ float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
+ DrawSprite(ctx, resolve, ScrollUpSprite, x, y, ScrollbarWidth, decExtent);
+ DrawSprite(ctx, resolve, ScrollDownSprite, x, y + InteriorH - incExtent, ScrollbarWidth, incExtent);
+
+ if (!PopupScroll.HasOverflow) return;
+
+ float trackTop = decExtent;
+ float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
+ var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
+ const float capH = 3f;
+ if (ScrollThumbTopSprite != 0 && ScrollThumbBottomSprite != 0 && th >= 2f * capH)
+ {
+ DrawSprite(ctx, resolve, ScrollThumbTopSprite, x, y + ty, ScrollbarWidth, capH);
+ DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty + capH, ScrollbarWidth, th - 2f * capH);
+ DrawSprite(ctx, resolve, ScrollThumbBottomSprite, x, y + ty + th - capH, ScrollbarWidth, capH);
+ }
+ else
+ {
+ DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty, ScrollbarWidth, th);
+ }
+ }
+
/// Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (,,
/// ,). Reuses the same geometry +
@@ -210,6 +377,31 @@ public sealed class UiMenu : UiElement
public override bool OnEvent(in UiEvent e)
{
+ // G5: scrollbar drag/wheel handling for the scrollable popup. Checked BEFORE
+ // the MouseDown-only early return below since these span MouseMove/Scroll too.
+ if (Scrollable && _open)
+ {
+ if (e.Type == UiEventType.MouseMove && _draggingPopupThumb)
+ {
+ DragPopupThumb(e.Data2);
+ return true;
+ }
+ if (e.Type == UiEventType.MouseUp && _draggingPopupThumb)
+ {
+ // Ending a thumb drag must not also close the popup — UiRoot fires a
+ // trailing Click on the same target after MouseUp, which this class
+ // does not handle (falls through as a no-op), so the popup stays open.
+ _draggingPopupThumb = false;
+ return true;
+ }
+ if (e.Type == UiEventType.Scroll)
+ {
+ ConfigurePopupScroll();
+ PopupScroll.ScrollByLines(-e.Data0);
+ return true;
+ }
+ }
+
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
@@ -218,6 +410,9 @@ public sealed class UiMenu : UiElement
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
// (outside the interior) just close the menu.
float ix = lx - Border, iy = ly - (-OuterH + Border);
+ if (Scrollable)
+ return HandleScrollablePopupMouseDown(ix, iy);
+
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
{
int col = (int)(ix / ColumnWidth);
@@ -243,4 +438,73 @@ public sealed class UiMenu : UiElement
_open = !_open; // toggle on button click
return true;
}
+
+ ///
+ /// G5: mouse-down dispatch for the scrollable popup — a click on the item
+ /// column picks a row (offset by the current scroll position, closing the
+ /// popup exactly like the grid path); a click on the scrollbar's up/down
+ /// buttons, track, or thumb drives scrolling and does NOT close the popup
+ /// (mirrors 's own MouseDown shape).
+ ///
+ private bool HandleScrollablePopupMouseDown(float ix, float iy)
+ {
+ if (ix >= 0 && ix < ColumnWidth && iy >= 0 && iy < InteriorH)
+ {
+ int row = (int)(iy / RowHeight);
+ int idx = VisibleTopRow + row;
+ if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
+ && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
+ {
+ OnSelect?.Invoke(Items[idx].Payload);
+ }
+ _open = false;
+ return true;
+ }
+
+ float scrollbarX = ColumnWidth;
+ if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH)
+ {
+ ConfigurePopupScroll();
+ float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
+ float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
+
+ if (iy < decExtent) { PopupScroll.ScrollByLines(-1); return true; }
+ if (iy >= InteriorH - incExtent) { PopupScroll.ScrollByLines(1); return true; }
+
+ float trackTop = decExtent;
+ float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
+ var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
+ if (iy >= ty && iy <= ty + th)
+ {
+ _draggingPopupThumb = true;
+ _popupThumbDragOffset = iy - ty;
+ }
+ else
+ {
+ PopupScroll.ScrollByPage(iy < ty ? -1 : 1);
+ }
+ return true; // scrollbar interaction never closes the popup
+ }
+
+ // Clicked the bevel ring — close, matching the grid path.
+ _open = false;
+ return true;
+ }
+
+ /// Continues an in-progress thumb drag ();
+ /// mirrors 's own MouseMove when _draggingThumb
+ /// case, reusing for the exact same thumb height.
+ private void DragPopupThumb(float ly)
+ {
+ float iy = ly - (-OuterH + Border);
+ ConfigurePopupScroll();
+ float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
+ float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
+ float trackTop = decExtent;
+ float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
+ var (_, thumbH) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
+ float travel = MathF.Max(1f, trackLen - thumbH);
+ float ratio = (iy - _popupThumbDragOffset - trackTop) / travel;
+ PopupScroll.SetPositionRatio(ratio);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index d5452b3d..7cf9e128 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -479,4 +479,48 @@ public class DragDropSpineTests
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
}
+
+ // ── G3 (vendor gate finding): occupied AllowDragSource=false cell ────────
+ // Regression introduced by F3 (Slice 6 review): AllowDragSource=false makes
+ // an OCCUPIED cell's IsDragSource false too, which — before the G3 fix —
+ // meant UiRoot's mousedown dispatch found no reason to claim the press at
+ // all (IsDragSource false, CapturesPointerDrag false, HandlesClick false)
+ // and fell all the way through to the IA-12 whole-window-drag fallback,
+ // exactly like an EMPTY cell. A vendor row must still capture its own
+ // press/click (selection) while genuinely never minting a drag payload.
+ [Fact]
+ public void OccupiedNonDragSourceSlotInsideDraggableWindow_capturesClick_doesNotMoveWindow()
+ {
+ var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
+ list.Cell.AllowDragSource = false; // vendor/salvage row shape
+ bool clicked = false;
+ list.Cell.Clicked = () => clicked = true;
+
+ root.OnMouseDown(UiMouseButton.Left, 20, 310);
+ root.OnMouseMove(40, 310); // would promote to drag if armed
+ Assert.Null(root.DragSource); // never mints a drag payload
+ Assert.Equal(10f, frame.Left); // window did NOT move
+ Assert.Equal(300f, frame.Top);
+
+ // HandlesClick (G3) routes this cell through UiRoot's case #4
+ // (CapturesPointerDrag/HandlesClick), the SAME branch a plain button
+ // uses — no _dragCandidate is ever armed (unlike IsDragSource, case
+ // #3), so there is no drag to distinguish an in-bounds move from: a
+ // release still inside the cell's own screen rect is an ordinary
+ // click regardless of the small in-cell move above, exactly like
+ // HandlesClickWidget_insideDraggableWindow_stillEmitsClick.
+ root.OnMouseUp(UiMouseButton.Left, 40, 310);
+ Assert.True(clicked);
+ }
+
+ [Fact]
+ public void OccupiedNonDragSourceSlotInsideDraggableWindow_hoverDoesNotShowMoveCursor()
+ {
+ var (root, _, list) = DraggableFrameWithSlot(0x5001u);
+ list.Cell.AllowDragSource = false;
+
+ root.OnMouseMove(20, 310); // hover over the vendor row, no press
+
+ Assert.False(root.HoverWindowMove);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
index 0687fd43..1e7a0bf7 100644
--- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
@@ -70,6 +70,82 @@ public sealed class VendorUiControllerTests
Assert.NotNull(controller);
}
+ [Fact]
+ public void Bind_FromRealDatFixture_BuyingAndSellingLists_ConfiguredForEmptySlotFill()
+ {
+ // G2 (vendor gate finding): the Buying/Selling pages' item strips
+ // never got the empty-slot fill the Items list has, so they showed
+ // the bare authored blue background instead. This proves the real
+ // LayoutDesc 0x21000012 fixture's 0x100000C5 (Buying list)/0x100000CE
+ // (Selling list) resolve to real UiItemList widgets and come out of
+ // Bind configured identically to the Items strip (F7b) — same
+ // single-row/horizontal-scroll/cell-size shape, fill enabled, a
+ // non-drag-source empty-slot factory wired, and the sibling
+ // scrollbar bound to the SAME list's scroll model. The lists stay
+ // UNPOPULATED (no AddItem call anywhere in this path) — staging is
+ // still deferred.
+ ImportedLayout layout = FixtureLoader.LoadVendor();
+ var screen = new UiRoot { Width = 1280f, Height = 800f };
+ RetailWindowHandle window = RetailWindowFrame.Mount(
+ screen,
+ layout.Root,
+ static _ => (0u, 0, 0),
+ new RetailWindowFrame.Options
+ {
+ WindowName = "vendor-fixture-smoke-2",
+ Chrome = RetailWindowChrome.Imported,
+ Visible = false,
+ });
+
+ var objects = new ClientObjectTable();
+ using var itemInteraction = new ItemInteractionController(
+ objects,
+ new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
+ new InteractionState(),
+ playerGuid: static () => 0u,
+ sendUse: null,
+ sendUseWithTarget: null,
+ sendWield: null,
+ sendDrop: null);
+ VendorUiController? controller = VendorUiController.Bind(
+ layout,
+ new VendorState(),
+ window,
+ static (_, _, _, _, _) => 0u,
+ objects,
+ static () => 0u,
+ itemInteraction,
+ new SelectionState(),
+ new StackSplitQuantityState(),
+ datFont: null,
+ debugFont: null,
+ static _ => (0u, 0, 0));
+ Assert.NotNull(controller);
+
+ var buyingList = Assert.IsType(layout.FindElement(VendorUiController.BuyingListId));
+ var sellingList = Assert.IsType(layout.FindElement(VendorUiController.SellingListId));
+ var buyingScrollbar = Assert.IsType(
+ layout.FindElement(VendorUiController.BuyingScrollbarId));
+ var sellingScrollbar = Assert.IsType(
+ layout.FindElement(VendorUiController.SellingScrollbarId));
+
+ foreach (UiItemList list in new[] { buyingList, sellingList })
+ {
+ Assert.True(list.SingleRow);
+ Assert.True(list.HorizontalScroll);
+ Assert.Equal(32f, list.CellWidth);
+ Assert.Equal(32f, list.CellHeight);
+ Assert.True(list.FillVisibleEmptySlots);
+ Assert.NotNull(list.EmptySlotFactory);
+ Assert.Equal(0, list.GetNumUIItems()); // never populated
+ }
+
+ Assert.Same(buyingList.Scroll, buyingScrollbar.Model);
+ Assert.True(buyingScrollbar.Horizontal);
+ Assert.Same(sellingList.Scroll, sellingScrollbar.Model);
+ Assert.True(sellingScrollbar.Horizontal);
+ }
+
private sealed class Harness
{
// F2/F3: a deterministic non-zero player coin total so the cost-text
@@ -310,7 +386,7 @@ public sealed class VendorUiControllerTests
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
Assert.Equal(
- $"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
+ $"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
@@ -338,7 +414,7 @@ public sealed class VendorUiControllerTests
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.Equal(
- $"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
+ $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
@@ -386,7 +462,7 @@ public sealed class VendorUiControllerTests
// selection.
Assert.Equal("Arrows", GetText(h.ItemNameText));
Assert.Equal(
- $"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
+ $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
// Player drags the slider to 40 AFTER selecting -- no re-click, no
@@ -396,7 +472,7 @@ public sealed class VendorUiControllerTests
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
Assert.Equal(
- $"cost {800:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
+ $"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
h.BuyButton.OnClick!.Invoke();
@@ -576,6 +652,45 @@ public sealed class VendorUiControllerTests
Assert.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
Assert.False(cell.IsDragSource);
Assert.Null(cell.GetDragPayload());
+ // G3 (vendor gate finding): a row must still CAPTURE its own press
+ // even though it never mints a drag payload -- see
+ // UiItemSlot.HandlesClick and the real-event-path test below.
+ Assert.True(cell.HandlesClick);
+ }
+
+ [Fact]
+ public void ShopRow_ClickEvent_SelectsItem_DespiteNotBeingADragSource()
+ {
+ // G3 (vendor gate finding): the F3 drag-suppression fix
+ // (AllowDragSource=false) left occupied vendor rows with
+ // IsDragSource==false -- before the G3 fix that meant UiRoot's
+ // mousedown dispatch found no reason to claim the press at all, so
+ // it fell through to the window-move fallback (hover showed the
+ // move-window cursor; a press dragged the whole panel instead of
+ // selecting a row). This drives the REAL UiItemSlot.OnEvent state
+ // machine (MouseDown then Click), the same sequence UiRoot's
+ // dispatch produces, rather than invoking the wired Clicked
+ // delegate directly -- proving the row still completes a press then
+ // click and drives selection.
+ const uint SecondArmorGuid = 0x60000110u;
+ var h = new Harness();
+ h.State.Apply(VendorGuid, Profile(), new[]
+ {
+ new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
+ new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
+ });
+ Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); // auto-selected first
+
+ UiItemSlot secondCell = h.ItemList.GetItem(1)!;
+ Assert.Equal(SecondArmorGuid, secondCell.ItemId);
+ Assert.False(secondCell.IsDragSource); // never a drag source (F3)
+ Assert.True(secondCell.HandlesClick); // but still claims its own press (G3)
+
+ secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.MouseDown));
+ secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.Click));
+
+ Assert.Equal(SecondArmorGuid, h.Selection.SelectedObjectId);
+ Assert.True(secondCell.Selected);
}
[Fact]
@@ -720,8 +835,22 @@ public sealed class VendorUiControllerTests
}
[Fact]
- public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
+ public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
{
+ // G4 (vendor gate finding): retail's close/pushpin button —
+ // gmVendorUI::HandleButtonClicks's 0x100000d6 case (pc:204147-204182)
+ // — with nothing staged in the Buying/Selling lists (this port never
+ // stages anything; Slice 6 territory) calls ONLY SetVisible(0),
+ // never gmVendorUI::CloseVendor (pc:202080, the range-watcher-
+ // unregister/session-teardown function VendorState.Close ports).
+ // The OLD port called VendorState.Close() directly from this button
+ // — an over-eager full teardown retail does not perform on an
+ // ordinary close. RuntimeVendorRangeQuery.EnforceRange (evaluated
+ // every frame regardless of window visibility) remains the sole
+ // path to a full close once the player actually leaves UseRadius —
+ // see Closed_HidesWindowAndClearsListAndText for that path,
+ // unaffected by this change since it calls VendorState.Close()
+ // directly rather than through this button.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
@@ -730,12 +859,25 @@ public sealed class VendorUiControllerTests
h.CloseButton.OnClick!.Invoke();
- // The panel never mutates VendorState directly — the ONLY path from
- // the close button to a cleared session is VendorState.Close()
- // itself, so VendorId reads back 0 through the owner's own public
- // surface, not a private field poke.
- Assert.Equal(0u, h.State.VendorId);
Assert.False(h.Window.IsVisible);
+ // Unlike the OLD port, the session itself is NOT torn down — the
+ // owner still reports the same open vendor in the background,
+ // matching retail's hidden-but-still-registered range watcher.
+ Assert.Equal(VendorGuid, h.State.VendorId);
+
+ // Re-approaching the SAME vendor (e.g. pressing Use again while
+ // still in range) now reaches retail's sameVendor==1 refresh-in-
+ // place path (VendorStateTransitionKind.Refreshed) instead of a
+ // from-scratch Opened, and reopens the window.
+ var kinds = new List();
+ h.State.Changed += t => kinds.Add(t.Kind);
+ h.State.Apply(VendorGuid, Profile(), new[]
+ {
+ new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
+ });
+
+ Assert.True(h.Window.IsVisible);
+ Assert.Equal([VendorStateTransitionKind.Refreshed], kinds);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
index 4b1a16fe..15240604 100644
--- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
@@ -175,4 +175,192 @@ public class UiMenuTests
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired);
}
+
+ // ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
+ // Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
+ // single column with a docked scrollbar, not a column-major grid — see
+ // VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
+ // (exercised by every test above, Scrollable left at its false default) is
+ // completely unaffected: it's a structurally different code path
+ // (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
+
+ private const int Border = 5; // RetailChromeSprites.Border
+
+ private static UiMenu.MenuItem[] MakeCategoryItems(int count)
+ => System.Linq.Enumerable.Range(0, count)
+ .Select(i => new UiMenu.MenuItem($"Category {i}", (object?)i))
+ .ToArray();
+
+ private static UiMenu MakeScrollableMenu(int itemCount = 18) => new UiMenu
+ {
+ Width = 100f, Height = 18f,
+ Items = MakeCategoryItems(itemCount),
+ Selected = (object?)0,
+ Scrollable = true,
+ RowsPerColumn = 6,
+ RowHeight = 18f,
+ ColumnWidth = 100f,
+ ScrollbarWidth = 16f,
+ ScrollButtonExtent = 16f,
+ };
+
+ /// Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a
+ /// point at popup-interior-local Y — derives the mapping
+ /// from the SAME public geometry properties production code reads, mirroring
+ /// how CategoryMenu_OpensAndSelectsThroughRealHitPath (VendorUiControllerTests)
+ /// derives its click point rather than hardcoding a pixel constant.
+ private static int RawY(UiMenu menu, float iy)
+ {
+ float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border;
+ return (int)(iy - outerH + Border);
+ }
+
+ /// Raw event Data1 ("lx") for a point at popup-interior-local X .
+ private static int RawX(float ix) => (int)(ix + Border);
+
+ [Fact]
+ public void Scrollable_18Categories_ConfiguresScrollExtentsFromAuthoredGeometry()
+ {
+ var menu = MakeScrollableMenu(18);
+ menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
+
+ // A wheel-scroll (no actual movement, Data0=0) is enough to configure
+ // PopupScroll from Items.Count/RowsPerColumn/RowHeight — the same
+ // "configure right before use" pattern UiItemList.LayoutCells follows.
+ menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
+
+ Assert.Equal(18 * 18, menu.PopupScroll.ContentHeight); // 18 items * 18px row height
+ Assert.Equal(6 * 18, menu.PopupScroll.ViewHeight); // 6 visible rows (the authored window)
+ Assert.True(menu.PopupScroll.HasOverflow); // 18 > 6 -> scrollbar warranted
+ }
+
+ [Fact]
+ public void Scrollable_ClickInFirstVisibleRow_SelectsItemZero_ThroughTheRealHitPath()
+ {
+ var menu = MakeScrollableMenu(18);
+ menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
+ object? fired = null;
+ menu.OnSelect = p => fired = p;
+
+ // Row 0's vertical center — same "row * RowHeight + RowHeight/2" shape
+ // the existing grid-mode tests already use for their row math.
+ int ly = RawY(menu, menu.RowHeight / 2f);
+ Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
+
+ Assert.Equal(0, fired);
+ }
+
+ [Fact]
+ public void Scrollable_ClickInScrollbarColumn_DoesNotSelectAnItem_AndKeepsThePopupOpen()
+ {
+ // Before G5's fix, a click at this X (where the OLD grid math would have
+ // treated it as "column 1") could have picked the WRONG item entirely —
+ // this X now belongs to the scrollbar, which must never fire OnSelect.
+ var menu = MakeScrollableMenu(18);
+ menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
+ var fired = new List