feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

The vendor window is retail's own: LayoutDesc 0x21000012, root
0x100000B7, found by enumerating all 101 layouts for the one
containing both known tab controls and clinched by the root's Type
0x10000017 — the literal UIElement::RegisterElementClass id for
gmVendorUI (pc:202075). Discovery evidence and the D0 read live in
the research doc's new §B.4.

D0 corrected two assumptions: retail's category "tabs" are a UiMenu
DROPDOWN fed by a hardcoded 18-row ordered category table (ported
bit-for-bit against our ItemType enum; list always scoped to exactly
one category, first-present wins, selection preserved across refresh
per retail's clamp), and the layout authors THREE tabs — Items
(browse, this slice), Buying and Selling (staged-transaction review,
Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's
mode-2 OpenTab opens. The non-default tabs render and switch pages
but stay inert, fenced in comments.

VendorUiController mounts Items: category dropdown, icon-cell item
row with the retained scrollbar, per-unit retail pricing via
VendorPricing.SellPrice (the vendor-stock path VendorProfile::
VendorSellPrice feeds), name/cost on selection. The panel is a pure
projection of VendorState — opens on populate, closes on clear; the
close button's VendorState.Close() is its only permitted mutation.
Nothing on the wire.

AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files
the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add
buttons, InqAcceptability). Twelve controller tests on a real-dat
fixture. Clean-room complete solution: 11,323 passed / 4 skipped /
0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 17:00:21 +02:00
parent 609a2dfda0
commit c721830e71
10 changed files with 17024 additions and 2 deletions

View file

@ -704,6 +704,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
placement,
amount),
late.Selection.IsWithinExternalContainerUseRange),
Vendor: new VendorRuntimeBindings(
d.Inventory.Vendor,
iconComposer.GetIcon),
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
Confirmations: new ConfirmationRuntimeBindings(
(type, context, accepted) =>

View file

@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AcDream.Core.Items;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmVendorUI</c>'s "Items" tab
/// (<c>VendorItemsUI</c>) — the browse-only scope of Slice 5.4. LayoutDesc
/// <c>0x21000012</c>, root <c>0x100000B7</c> — discovered and verified
/// against the named-retail decomp in
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §B.3/§B.4.
///
/// <para>
/// <b>Three tabs, not two.</b> The contract (world-interaction-completion.md,
/// Slice 5 decisions) anticipated a "browse/Buy" tab plus a "Sell" tab. The
/// dat authors three DAT-labeled tabs — "Items" (<see cref="ItemsTabId"/>),
/// "Buying" (<see cref="BuyingTabId"/>), "Selling" (<see cref="SellingTabId"/>) —
/// each a state/page pair. Retail's <c>gmVendorUI::OpenVendor</c>
/// (<c>pc:203650</c>) opens tab <see cref="ItemsTabId"/> for the ordinary
/// (mode 2) open this slice always exercises — matching the contract's
/// "browse/Buy tab" language, just under its actual dat name. "Buying"
/// (<c>VendorBuyUI</c>, staged-purchase review) and "Selling"
/// (<c>VendorSellUI</c>, staged-sale review) are Slice 6 territory: their
/// tab buttons render and switch the visible page (so the authored layout
/// looks complete), but neither page is ever populated by this controller —
/// clicking them just shows an empty page. No buy/sell action, no
/// <c>InqAcceptability</c>, no currency mutation happens anywhere in this
/// file.
/// </para>
///
/// <para>
/// <b>Category filter = a dropdown, not more tabs.</b> Retail's
/// <c>VendorItemsUI::AddTypeFilter</c>/<c>::ListContainsType</c>
/// (<c>pc:199667</c>/<c>200132</c>, D0 read in the research doc's §B.4)
/// populate <see cref="UiMenu"/> <see cref="TypeFilterMenuId"/> with one
/// entry per <see cref="CategoryFilters"/> row the vendor's stock actually
/// contains, in retail's fixed table order, and the item list is ALWAYS
/// scoped to exactly one selected category (there is no "show everything"
/// state — an empty selection retail-authentically shows zero items).
/// </para>
///
/// <para>
/// <b>Lifecycle: pure state projection.</b> This controller owns no
/// session/transaction state of its own. It opens/repopulates/closes purely
/// by observing <see cref="VendorState.Changed"/>; the one mutation it may
/// trigger is the close button calling <see cref="VendorState.Close"/> —
/// going through the owner (client-local per research §A.4, nothing on the
/// wire), never writing fields directly.
/// </para>
/// </summary>
public sealed class VendorUiController : IRetainedPanelController
{
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;
/// <summary>
/// Retail's ordered category table, transcribed verbatim from
/// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain
/// (<c>pc:200792-201005</c>). Order matters: it's the display/insertion
/// order AND the "first present entry wins on fresh open" default. Every
/// mask cross-checked bit-for-bit against
/// <see cref="ItemType"/> — see the research doc's §B.4 table.
/// </summary>
private static readonly (string Label, ItemType Mask)[] CategoryFilters =
[
("Armor", ItemType.Armor), // 0x2
("Books, Paper", ItemType.Writable), // 0x2000
("Clothing", ItemType.Clothing), // 0x4
("Containers", ItemType.Container), // 0x200
("Food", ItemType.Food), // 0x20
("Gems", ItemType.Gem), // 0x800
("Jewelry", ItemType.Jewelry), // 0x8
("Keys, Tools", ItemType.TinkeringTool | ItemType.Key), // 0x20004000
("Miscellaneous", ItemType.Useless | ItemType.Misc | ItemType.Creature), // 0x490
("Services", ItemType.Service), // 0x100000
("Spell Components", ItemType.SpellComponents), // 0x1000
("Trade Notes", ItemType.PromissoryNote), // 0x40000
("Weapons", ItemType.Weapon), // 0x101
("Mana Stones", ItemType.ManaStone), // 0x80000
("Magic Items", ItemType.Caster), // 0x8000
("Alchemical Items", ItemType.CraftAlchemyIntermediate | ItemType.CraftAlchemyBase), // 0x4800000
("Cooking Items", ItemType.CraftCookingBase), // 0x400000
("Fletching Items", ItemType.CraftFletchingIntermediate | ItemType.CraftFletchingBase), // 0x9000000
];
private readonly VendorState _vendor;
private readonly RetailWindowHandle _window;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
private readonly 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;
private readonly UiMenu _typeMenu;
private readonly UiText _itemNameText;
private readonly UiText _itemCostText;
private readonly UiButton? _close;
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
private int _selectedCategoryIndex = -1;
private uint _selectedItemGuid;
private bool _disposed;
private VendorUiController(
VendorState vendor,
RetailWindowHandle window,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
UiElement itemsPage,
UiElement buyingPage,
UiElement sellingPage,
UiElement itemsTab,
UiElement buyingTab,
UiElement sellingTab,
UiItemList itemList,
UiScrollbar? itemScrollbar,
UiMenu typeMenu,
UiText itemNameText,
UiText itemCostText,
UiButton? close)
{
_vendor = vendor;
_window = window;
_resolveIcon = resolveIcon;
_itemsPage = itemsPage;
_buyingPage = buyingPage;
_sellingPage = sellingPage;
_itemsTab = itemsTab;
_buyingTab = buyingTab;
_sellingTab = sellingTab;
_itemList = itemList;
_typeMenu = typeMenu;
_itemNameText = itemNameText;
_itemCostText = itemCostText;
_close = close;
_itemList.Columns = 1;
_itemList.SingleRow = true;
_itemList.HorizontalScroll = true;
_itemList.CellWidth = 32f;
_itemList.CellHeight = 32f;
if (itemScrollbar is not null)
{
itemScrollbar.Model = _itemList.Scroll;
itemScrollbar.Horizontal = true;
}
_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 = () => _vendor.Close();
ShowTab(VendorPanelTab.Items);
ClearContent();
_vendor.Changed += OnVendorChanged;
}
public static VendorUiController? Bind(
ImportedLayout layout,
VendorState vendor,
RetailWindowHandle window,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(vendor);
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(resolveIcon);
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;
}
UiButton? close = layout.FindElement(CloseId) as UiButton;
UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar;
return new VendorUiController(
vendor,
window,
resolveIcon,
itemsPage,
buyingPage,
sellingPage,
itemsTab,
buyingTab,
sellingTab,
itemList,
itemScrollbar,
typeMenu,
itemNameText,
itemCostText,
close);
}
private enum VendorPanelTab { Items, Buying, Selling }
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:
case VendorStateTransitionKind.Refreshed:
ShowTab(VendorPanelTab.Items);
RebuildCategories();
_window.Show();
break;
case VendorStateTransitionKind.Closed:
case VendorStateTransitionKind.Reset:
ClearContent();
ShowTab(VendorPanelTab.Items);
_window.Hide();
break;
}
}
/// <summary>
/// Port of the tail of <c>VendorItemsUI::OpenVendor</c>
/// (<c>pc:200789-201022</c>): flush the menu, walk
/// <see cref="CategoryFilters"/> in order keeping only entries
/// <c>ListContainsType</c> would keep, then re-derive the selected index
/// with retail's exact clamp (preserve if still valid, else clamp to the
/// last entry, else 0).
/// </summary>
private void RebuildCategories()
{
IReadOnlyList<VendorShopItem> items = _vendor.Items;
_presentCategories.Clear();
foreach ((string label, ItemType mask) in CategoryFilters)
{
uint maskValue = (uint)mask;
bool present = false;
for (int i = 0; i < items.Count; i++)
{
if (((items[i].ItemType ?? 0u) & maskValue) != 0u)
{
present = true;
break;
}
}
if (present) _presentCategories.Add((label, mask));
}
int selected = _selectedCategoryIndex;
if (selected >= _presentCategories.Count - 1)
selected = _presentCategories.Count - 1;
if (selected < 0)
selected = 0;
_selectedCategoryIndex = selected;
_typeMenu.Items = _presentCategories
.Select(entry => new UiMenu.MenuItem(entry.Label, (object)(uint)entry.Mask))
.ToArray();
_typeMenu.Selected = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? (object)(uint)_presentCategories[_selectedCategoryIndex].Mask
: null;
RebuildItemList();
}
/// <summary>
/// User picked a different dropdown entry — port of
/// <c>VendorItemsUI::UpdateItemsList</c>'s explicit-mask branch
/// (<c>arg2 != 0</c>, <c>pc:201039-201040</c>).
/// </summary>
private void SelectCategory(uint mask)
{
int index = _presentCategories.FindIndex(entry => (uint)entry.Mask == mask);
if (index < 0 || index == _selectedCategoryIndex) return;
_selectedCategoryIndex = index;
_typeMenu.Selected = (object)mask;
RebuildItemList();
}
/// <summary>
/// Port of <c>VendorItemsUI::UpdateItemsList</c>'s item-insertion loop
/// (<c>pc:201059-201158</c>): only items whose type intersects the
/// active mask are shown. There is no "all categories" state — an empty
/// <see cref="_presentCategories"/> (no vendor stock at all, or nothing
/// selected) shows zero rows, matching retail.
/// </summary>
private void RebuildItemList()
{
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
? _presentCategories[_selectedCategoryIndex].Mask
: default;
uint maskValue = (uint)activeMask;
IReadOnlyList<VendorShopItem> items = _vendor.Items;
bool selectionStillPresent = false;
using (_itemList.DeferLayout())
{
_itemList.Flush();
if (maskValue != 0u)
{
foreach (VendorShopItem item in items)
{
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true;
uint icon = _resolveIcon((ItemType)(item.ItemType ?? 0u), item.IconId, 0u, 0u, 0u);
var cell = new UiItemSlot
{
SpriteResolve = _itemList.SpriteResolve,
SlotIndex = _itemList.GetNumUIItems(),
};
cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == _selectedItemGuid;
VendorShopItem captured = item;
cell.Clicked = () => SelectItem(captured);
_itemList.AddItem(cell);
}
}
}
if (!selectionStillPresent)
ClearSelection();
}
/// <summary>
/// Port of retail row selection — updates <c>m_itemNameText</c>/
/// <c>m_itemCostText</c> with the retail-correct PER-UNIT price
/// (<c>VendorProfile::VendorSellPrice</c>, quantity 1 — the vendor
/// sells this item TO the player at the vendor's own sell rate;
/// <see cref="VendorPricing.SellPrice"/>'s naming-inversion warning
/// applies, see that type's doc comment).
/// </summary>
private void SelectItem(VendorShopItem item)
{
_selectedItemGuid = item.ItemGuid;
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
{
if (_itemList.GetItem(i) is { } cell)
cell.Selected = cell.ItemId == _selectedItemGuid;
}
SetPlainText(_itemNameText, item.Name ?? string.Empty);
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: 1);
string costText = profile.AlternateCurrencyWcid == 0u
? price.ToString(CultureInfo.InvariantCulture)
: $"{price.ToString(CultureInfo.InvariantCulture)} {profile.AlternateCurrencyPluralName}";
SetPlainText(_itemCostText, costText);
}
private void ClearSelection()
{
_selectedItemGuid = 0u;
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);
}
private void ClearContent()
{
_presentCategories.Clear();
_selectedCategoryIndex = -1;
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
_typeMenu.Selected = null;
_itemList.Flush();
ClearSelection();
}
private static void SetPlainText(UiText text, string value)
{
IReadOnlyList<UiText.Line> lines = string.IsNullOrEmpty(value)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(value, text.DefaultColor) };
text.LinesProvider = () => lines;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_vendor.Changed -= OnVendorChanged;
RetailTabBinding.SetClick(_itemsTab, null);
RetailTabBinding.SetClick(_buyingTab, null);
RetailTabBinding.SetClick(_sellingTab, null);
_typeMenu.OnSelect = null;
_typeMenu.ButtonLabelProvider = null;
if (_close is not null)
_close.OnClick = null;
}
}

View file

@ -168,6 +168,10 @@ public sealed record AppraisalRuntimeBindings(
Action<uint, string> SendSetInscription,
Action<string> DisplaySystemMessage);
public sealed record VendorRuntimeBindings(
VendorState State,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
@ -184,6 +188,7 @@ public sealed record RetailUiRuntimeBindings(
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
ExternalContainerRuntimeBindings ExternalContainer,
VendorRuntimeBindings Vendor,
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
@ -255,6 +260,7 @@ public sealed class RetailUiRuntime : IDisposable
MountPlugins();
MountInventory();
MountExternalContainer();
MountVendor();
MountItemCooldowns();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
@ -272,6 +278,7 @@ public sealed class RetailUiRuntime : IDisposable
WindowNames.Combat,
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
WindowNames.Vendor,
]);
}
@ -322,6 +329,7 @@ public sealed class RetailUiRuntime : IDisposable
public InventoryController? InventoryPanelController { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -1897,6 +1905,75 @@ public sealed class RetailUiRuntime : IDisposable
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
}
/// <summary>
/// Slice 5.4: the retail vendor "Items" browse tab (LayoutDesc
/// 0x21000012, root 0x100000B7 — see
/// docs/research/2026-08-08-slice5-vendor-browse-research.md §B.3/§B.4
/// for the discovery + D0 category-filter read). Two-step mount
/// (Mount then Bind then AttachController) matches
/// <see cref="MountExternalContainer"/>: the controller needs the
/// <see cref="RetailWindowHandle"/> to Show/Hide itself as
/// <see cref="VendorState"/> opens/closes, so the handle must exist
/// before the controller is constructed.
/// </summary>
private void MountVendor()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[M4] vendor: LayoutDesc 0x21000012 root 0x100000B7 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Vendor,
// Root 0x100000B7 authors only the tiled center surface
// (backdrop element 0x1000008D provides its own fill); the
// common floating-window bevel surrounds it — same shape as
// ExternalContainerController's 0x21000008/0x10000063.
Chrome = RetailWindowChrome.NineSlice,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
MinWidth = root.Width,
MinHeight = root.Height,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
DrawChromeCenter = false,
});
VendorRuntimeBindings b = _bindings.Vendor;
VendorController = VendorUiController.Bind(layout, b.State, handle, b.ResolveIcon);
if (VendorController is null)
{
Console.WriteLine("[M4] vendor: required authored controls are missing.");
return;
}
Host.WindowManager.AttachController(WindowNames.Vendor, VendorController);
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
}
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;

View file

@ -22,4 +22,5 @@ public static class WindowNames
public const string MiniGame = "mini-game";
public const string Vitae = "vitae";
public const string Examination = "examination";
public const string Vendor = "vendor";
}