acdream/docs/research/2026-08-08-slice5-vendor-browse-research.md
Erik c721830e71
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
feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
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>
2026-08-07 17:00:21 +02:00

54 KiB

Slice 5 research — vendor browse lifecycle

Date: 2026-08-08 Scope: Slice 5 of docs/plans/2026-07-23-world-interaction-completion.md — "Vendor use opens the authored vendor surface, publishes its inventory, and supports retail selection/browsing." Buy/sell transactions, quantities, and authoritative reconciliation are Slice 6 and explicitly fenced out below (Section D).

Verified starting point: repo HEAD at research time was fa0c053e ("docs(physics): #347 closed WITHOUT a code change..."), which is at/after the required fa0c053e gate. This document makes no code changes; it is a research foundation only.

Reference hierarchy used (per CLAUDE.md): named-retail decomp (docs/research/named-retail/acclient_2013_pseudo_c.txt + symbols.json) is the top oracle for client behavior. ACE (references/ACE/) is authoritative for what the server sends/validates. holtburger (references/holtburger/) is authoritative for what a real client actually sends and how a full client models the state. Chorizite.ACProtocol (references/Chorizite.ACProtocol/) is a clean-room field-order cross-check. Where two sources could plausibly disagree, this document says which one the project's hierarchy prefers — in every case investigated here they agreed byte-for-byte.


A. The wire (browse only)

A.1 — What the client sends to open a vendor

There is no vendor-specific open message. Opening a vendor rides the ordinary GameAction 0x36 — UseItem (Use) action — the exact same opcode already used for every other "double-click to use" interaction (containers, doors, levers, Slice 4's equipped-child picking, etc.).

  • ACE: GameActionType.Use = 0x0036references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:28. Handler: GameActionUseItem.Handlesession.Player.HandleActionUseItem(itemGuid)references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionUseItem.cs:5-16. HandleActionUseItem resolves the guid, optionally walks the player to it, then calls TryUseItem(item)references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-241. TryUseItem calls item.OnActivate(this), which (for a Vendor, whose ActivationResponse includes Use) dispatches virtually to Vendor.ActOnUsereferences/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:229-266.
  • holtburger: AppAction::OpenShop { vendor }result.commands.push(ClientCommand::Use(vendor))references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:40-49. Confirms a real client also just sends the ordinary Use command; there is no ClientCommand::OpenVendor.
  • acdream already sends this opcode today: InteractRequests.UseOpcode = 0x0036src/AcDream.Core.Net/Messages/InteractRequests.cs:29. No new outbound message is needed for Slice 5.
  • The retail client's own use-classification policy already special-cases vendors: PublicWeenieFlags.Vendor = 0x00000200 (retail PublicWeenieDesc::BitfieldIndex, acclient.h:6431, verified at binary 0x005884B6/0x00588752) — src/AcDream.Core/Items/ItemInteractionPolicy.cs:21. A vendor NPC's Useability marks it IsUseable, so ItemInteractionPolicy.DecideUse falls into the plain SendUse + IncrementBusy branch (src/AcDream.Core/Items/ItemInteractionPolicy.cs:267-288) — the exact same path already exercised by every other useable NPC/object.

A.2 — What the server sends back: ApproachVendor (GameEvent 0x0062)

GameEventType.ApproachVendor = 0x0062references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs:16; already declared in acdream at src/AcDream.Core.Net/Messages/GameEventType.cs:26 (currently unhandled — GameEventDispatcher will count it in UnhandledCounts until Slice 5 wires a handler).

Full field-by-field wire layout, cross-verified across three independent sources (ACE's writer, retail's VendorProfile::UnPack/ItemProfile::UnPack decompiled reader, and Chorizite's generated reader/writer) with zero disagreement:

# Field Type ACE source Retail source Chorizite name
1 Vendor's own guid u32 GameEventApproachVendor.cs:14 Handle_VendorInfo edi = *(u32*)eax (pc:370614) Vendor_VendorInfo.ObjectId
2 MerchandiseItemTypes (categories the vendor buys) u32 (ItemType) :17 VendorProfile::UnPack item_types (pc:484940) VendorProfile.Categories
3 MerchandiseMinValue u32 :18 min_value (pc:484943) VendorProfile.MinValue
4 MerchandiseMaxValue u32 :19 max_value (pc:484946) VendorProfile.MaxValue
5 DealMagicalItems u32 (0/1) :21 Convert.ToUInt32 magic (pc:484949) VendorProfile.DealsMagic (bool)
6 BuyPrice (rate applied when the vendor buys from the player — i.e. what the player receives when selling) float :23 buy_price (pc:484952) VendorProfile.BuyPrice
7 SellPrice (rate applied when the vendor sells to the player — i.e. what the player pays when buying) float :24 sell_price (pc:484955) VendorProfile.SellPrice
8 Alternate-currency wcid (AlternateCurrency ?? 0) u32 (dat id) :27 trade_id.id (pc:484960) VendorProfile.CurrencyId
9 Player's current holding of that currency + amount just spent (0 if pyreal vendor) u32 :37,44 trade_num (pc:484961) VendorProfile.CurrencyAmount
10 Alt-currency plural name (empty string if pyreal vendor) String16L :40,45 trade_name (PStringBase<char>::UnPack, pc:484963) VendorProfile.CurrencyName
11 Item count u32 (vendor.DefaultItemsForSale.Count + UniqueItemsForSale.Count) :50 PackableList<ItemProfile>::UnPack count prefix (pc:370625) Vendor_VendorInfo.Items (list length prefix)
12..N Per-item entries (see below) :52-61 List<ItemProfile>

Field-name naming trap (worth flagging loudly for the contract): retail's client-side VendorProfile::VendorSellPrice(profile, pwd, stack) computes what you pay to buy an item — it uses the sell_price field (field 7 above), i.e. "the price at which the vendor is selling." ACE's server-side Vendor.GetSellCost (Vendor.cs:577-585) is the same computation and uses SellPrice (same field). Conversely VendorBuyPrice/GetBuyCost (what the vendor pays when you sell to it) uses buy_price/BuyPrice. The names read backwards from an English-first intuition ("BuyPrice" sounds like "price I pay to buy," but it's actually "price the vendor pays when buying from you"). Any pricing helper in acdream should carry an explicit doc comment quoting this inversion.

Per-item encoding — confirmed to be the full CreateObject-style weenie description, not a compact profile. Retail's ItemProfile::UnPack (pc:484668-484742, symbol 0x005D1910):

  1. Packed u32: low 24 bits = stack size (sign-extended; 0xFFFFFF-1 ⇒ unlimited supply), high 8 bits = pwdType (-1 = new PublicWeenieDesc, 1 = legacy OldPublicWeenieDesc, always -1 in practice). Matches ACE's writer exactly: Writer.Write(stackSize & 0xFFFFFF | -1 << 24)GameEventApproachVendor.cs:58.
  2. u32 item guid (iid) — read before the desc body, i.e. it is a distinct field from whatever the desc-body parser reads.
  3. The desc body itself: pwd->vtable->UnPack(arg2, arg3) — this is the same PublicWeenieDesc unpack used by ordinary CreateObject, minus model/physics data. ACE confirms: obj.SerializeGameDataOnly(writer)SerializeCreateObject(writer, gamedataonly: true, ...)references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs:39-58. SerializeCreateObject writes Guid first (matching step 2 above, since gamedataonly still writes the guid, just skips SerializeModelData + SerializePhysicsData), then the weenieFlags/name/wcid/icon/itemType/ objDescriptionFlags fixed prefix and the same conditional tail fields (PluralName, ItemsCapacity, Value, Useability, StackSize, WielderId, ValidSlots, etc.) that every CreateObject carries. Chorizite's PublicWeenieDesc.generated.cs (Read/Write, lines 226-480) is the exact field-by-field cross-check and matches WorldObject_Networking.cs:56-130 bit-for-bit.
  4. Retail's gmVendorUI::OpenVendor (pc:203650, 0x004C4BA0) then materializes each list item as a full CWeenieObject via CFactory::MakeCWeenieObject and registers it in ClientObjMaintSystem/CObjectMaint — the same object table every other spawned entity lives in (pc:203720-203748). Vendor items are not a separate lightweight vendor-item record in retail; they are ordinary client objects with no spatial presence. Recommendation: acdream's ClientObjectTable should host vendor items the same way (see C.2/C.5).

decode_vendor_item_supply in holtburger confirms the sign-extension handling for the packed stack-size field independently: references/holtburger/crates/holtburger-world/src/hydration.rs:33-40 ((packed << 8) as i32 >> 8, negative ⇒ unlimited), with tests at hydration.rs:320-330.

A.3 — How browsing stays current; what closes it server-side

The list is a full snapshot, not incrementally updated. Each ApproachVendor is a complete replace (whole VendorProfile + whole item list); there is no delta/patch opcode for vendor contents in ACE or the retail decomp. A fresh ApproachVendor arrives on: initial open (Vendor.ActOnUseApproachVendor(player, VendorType.Open), Vendor.cs:246-266), after a successful buy (FinalizeBuyTransactionvendor.ApproachVendor(this, VendorType.Buy, ...), Player_Commerce.cs:112), and after a successful sell (ProcessItemsForPurchaseApproachVendor(player, VendorType.Sell), Vendor.cs:661). All three are Slice 6 triggers (buy/sell); Slice 5 only needs to handle the initial-open case, but the parser/state owner should be built expecting replace semantics (mirroring how ExternalContainerState/ViewContents already model "authoritative full replace," see C.2).

Server-side close is polling, not a push. Vendor.CheckClose runs every closeInterval = 1.5f seconds (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367) and, if the last-interacting player has moved beyond UseRadius, calls EmoteManager.DoVendorEmote(VendorType.Close, ...) — this is a cosmetic emote broadcast (goodbye animation/chat text via EmoteManager.DoVendorEmotereferences/ACE/Source/ACE.Server/WorldObjects/Managers/EmoteManager.cs:1763-1770), not a distinct wire opcode that tells the client to close its panel. There is no server→client "vendor closed" GameEvent.

Retail's own client independently tracks distance and closes locally: on open, gmVendorUI::OpenVendor registers a range watcher — CPlayerSystem::RegisterObjectRangeHandler(..., eax->id, eax->pwd._useRadius, ...) (pc:203677, 0x004C4C34) — and gmVendorUI::OnObjectRangeExit (pc:199486, 0x004C02F0) calls gmVendorUI::CloseVendor when that handler fires. gmVendorUI::CloseVendor (pc:202080, 0x004C3020) clears the buy/sell/items sub-UI, calls CPlayerSystem::UnregisterObjectRangeHandler, and resets shop state. This confirms the client, not the server, owns the close decision — retail's client independently watches distance client-side (belt-and-suspenders with ACE's server-side emote, which is purely cosmetic).

Also: reopening the same vendor id while already open does not tear down the sub-UIs — gmVendorUI::OpenVendor only calls CloseVendor(true) (same-vendor flag) when shopVendorID was already set and matches, which skips the sub-UI clear (pc:203664-203668). Reopening a different vendor while one is open closes the old one first. This matters for the reset lifecycle contract (C.2): "reset on any new ApproachVendor for a different guid; refresh-in-place on a same-guid ApproachVendor."

A.4 — What the client sends when the panel closes

Nothing. Closing the vendor panel is entirely client-local UI state with no outbound wire message. Confirmed in two independent places:

  • holtburger: AppAction::ClearVendor => { state.view.vendor = None; }references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:95-98. No command is pushed to the outbound queue.
  • ACE has no HandleAction*CloseVendor* handler anywhere in references/ACE/Source/ACE.Server/WorldObjects/ (only HandleActionBuyItem/HandleActionSellItem exist — Player_Commerce.cs:22, 126).
  • Retail's gmVendorUI::CloseVendor (pc:202080) is a pure UI-teardown function; it does not build or send a message.

There is one indirect trigger: holtburger clears the vendor view when a trade (player-to-player) starts (ClientViewEvent::TradeStateUpdated, trade_vendor.rs:134-137), which is a local UX choice (only one modal commerce surface at a time), not a protocol requirement.


B. Retail client mechanism (named-retail decomp)

685 lines in acclient_2013_pseudo_c.txt reference "Vendor." The full symbol family (docs/research/named-retail/symbols.json), with addresses:

Symbol Address Role
ClientUISystem::Handle_VendorInfo 0x00565D10 Entry point for the inbound ApproachVendor message; unpacks VendorProfile + item list, then fans out via CM_Vendor::SendNotice_OpenVendor
VendorProfile::UnPack / ::VendorProfile 0x005D1D20 / 0x005D1BE0 Wire unpack for the profile header (field table above)
ItemProfile::UnPack / ::Pack 0x005D1910 / 0x005D1890 Wire unpack for each shop item
VendorProfile::VendorSellPrice / ::VendorBuyPrice 0x005D1B00 / 0x005D1B70 Client-local price computation (see B.1)
VendorProfile::InqAcceptability / ::IsAcceptable 0x005D1A90 / 0x005D1B50 Sell-eligibility filter (item type/value/magic) — Slice 6 territory but touches the browse UI (see D)
ShopSystem::BuyPrice / ::SellPrice 0x006B6120 / 0x006B6180 The actual rounding formula (see B.1)
gmVendorUI::Create 0x004C26A0 Panel factory (LayoutDesc-driven UIElement construction)
gmVendorUI::OpenVendor 0x004C4BA0 Panel-level open handler — receives the already-unpacked profile+list
gmVendorUI::CloseVendor 0x004C3020 Panel-level close/reset
gmVendorUI::ResetShopState 0x004C26D0 Full shop-state reset (buy list, sell list, filters)
gmVendorUI::OpenTab 0x004C0390 Switches the Buy/Sell tab
gmVendorUI::OnObjectRangeExit 0x004C02F0 Client-side distance-close trigger (see A.3)
VendorItemsUI::OpenVendor / ::UpdateItemsList / ::UpdateItemsUI / ::AddTypeFilter / ::ListContainsType 0x004C16D0 / 0x004C1EA0 / 0x004C38E0 / 0x004C05C0 / 0x004C0D90 The item-list sub-panel: population, category-tab filtering
VendorBuyUI::OpenVendor / ::UpdateBuyUI / ::UpdateTotalValue / ::UpdateTransactionValue 0x004C49D0 / 0x004C0E10 / 0x004C33D0 / 0x004C3150 Buy-side sub-panel (Slice 6, but shares the item list — see D)
VendorSellUI::OpenVendor / ::AddItemToSell / ::DragItemAcceptable 0x004C2FB0 / ... Sell-side sub-panel (Slice 6)
CM_Vendor::SendNotice_OpenVendor / ::SendNotice_CloseVendor / ::SendNotice_AddItemToSell 0x0055F...-family Internal (non-network) client notice-bus fanout from the wire handler to the UI listeners
CM_Vendor::Event_Buy / ::Event_Sell 0x006AA0F0 / 0x006AA000 Outbound buy/sell message builders (Slice 6)

B.1 — Panel open/close, list population, tabs, price display

ClientUISystem::Handle_VendorInfo (pc:370610-370649) is the routing entry point: reads the vendor guid, unpacks VendorProfile, unpacks the PackableList<ItemProfile>, then calls CM_Vendor::SendNotice_OpenVendor(vendorGuid, &profile, &itemList, mode) where mode is 2 (Open/Buy tab) or 3 (Sell tab), chosen by whether this reply correlates to the client's own tracked attemptOpenVendorID (a client-side request-correlation token, analogous to acdream's RuntimeInteractionTransactionState request tracking — see C.1). If a pending sell-item id (attemptSaleObjectID) is also armed, a second internal notice (SendNotice_AddItemToSell) fires. For Slice 5's browse scope, the practical rule is: opening a vendor via ordinary Use always yields mode 2 (Buy/browse tab), since attemptOpenVendorID is only armed by a sell-drag action (Slice 6).

gmVendorUI::OpenVendor(vendorId, profile, itemList, mode) (pc:203650-...) is the panel controller reached via that notice:

  1. If a different vendor is currently open, it force-closes the old one first (CloseVendor(false)); reopening the same vendor id refreshes in place (CloseVendor(true), skipping sub-UI teardown).
  2. Registers a distance watcher: CPlayerSystem::RegisterObjectRangeHandler keyed to the vendor's own UseRadius (pwd._useRadius) — this is what drives the client-local auto-close (A.3).
  3. Copies the profile and item list into panel-owned storage (VendorProfile::operator=, PackableList<ItemProfile>::operator=).
  4. Materializes every list item as a full CWeenieObject in ClientObjMaintSystem (already covered in A.2 point 4) — items you have never seen a CreateObject for still get a real client object, since the PublicWeenieDesc in the ItemProfile is sufficient to construct one.
  5. Opens a tab by authored control id: UIElement_Panel::OpenTab(panel, 0x100000B9) for mode 2 (Buy/browse) or 0x100000BB for mode 3 (Sell) — pc:203791/203801. These are concrete DAT UI-element ids baked into the executable; they are strong candidates for the browse panel's own tab-control ids once the panel's own LayoutDesc is dat-extracted (see B.3 — the numeric neighborhood matches other already-confirmed vendor- adjacent ids like the examination window's 0x100000B5/B6/71/72).

Category/type filtering happens in VendorItemsUIAddTypeFilter (0x004C05C0) and ListContainsType (0x004C0D90) drive the tab-per- item-type UI (weapons/armor/misc tabs seen in retail vendor windows), fed by UpdateItemsList/UpdateItemsUI (0x004C1EA0/0x004C38E0). This is squarely "supports retail selection/browsing" (in Slice 5's stated scope) but has not been read in exhaustive detail here — flagged as an open question (see bottom).

Price display is computed client-side, not sent pre-computed. Neither VendorProfile nor ItemProfile/PublicWeenieDesc carries a "displayed price" field — only the item's raw Value and the vendor's buy_price/ sell_price rates. The client computes the number shown in the list itself:

  • VendorProfile::VendorSellPrice(profile, pwd, stackCount) (pc:484801-484813, 0x005D1B00) — "price to buy this from the vendor" — calls ShopSystem::SellPrice(perUnitValue, itemType, sell_price, stackCount).
  • ShopSystem::SellPrice (pc:702107-702128, 0x006B6180): max(1, ceil(rate * value * stackCount - 0.1)), where rate = 1.15 if itemType == PromissoryNote else sell_price.
  • ShopSystem::BuyPrice (pc:702082-702103, 0x006B6120): max(1, floor(rate * value * stackCount + 0.1)), where rate = 1.0 if PromissoryNote else buy_price.

These are byte-identical to ACE's server-side Vendor.GetSellCost/ GetBuyCost (Vendor.cs:573-599: Math.Max(1, (uint)Math.Ceiling((sellRate * value) - 0.1)) and Math.Max(1, (int)Math.Floor((buyRate * value) + 0.1))). Three-way cross-check (retail decomp, ACE server formula, and the fact that ACE's formula is what actually determines the transaction) with zero disagreement. Recommendation: port ShopSystem::BuyPrice/SellPrice as a small pure Core function (e.g. VendorPricing.SellPrice/BuyPrice) so Slice 5's item-list rows can show the retail-correct number, with the naming-inversion warning from A.2 called out in the doc comment.

B.2 — How retail reacts to the approach-vendor event; teardown

Already covered in A.3/B.1: ClientUISystem::Handle_VendorInfo is the sole routing entry (there is no separate "OpenVendor" opcode — everything comes through the one ApproachVendor/0x0062 GameEvent), it fans out via the internal CM_Vendor::SendNotice_OpenVendor notice bus to gmVendorUI::RecvNotice_OpenVendor (a listener registration, symbol present in symbols.json but not read in detail here), and gmVendorUI is the state object that owns shopVendorID, shopVendorProfile, shopItemProfileList, and the sub-UI panels. Teardown is gmVendorUI::CloseVendor (client-local, distance-triggered or different-vendor-triggered — never server-pushed).

B.3 — LayoutDesc/DAT identity

docs/research/retail-ui/UI-DATAIDS.md:229-236 explicitly states the vendor panel (along with Trade/Allegiance/Fellowship/Combat/Tooltip) is "confirmed to exist... but their per-panel sprite IDs live entirely inside their LayoutDesc records... [and] will populate this section after the first dat-extraction pass." The vendor panel's own top-level LayoutDesc id (the 0x210000xx-range analog of the examination window's 0x2100006B) has not yet been dat-extracted or recorded anywhere in the repo. Slice 5 will need to find it via the same import-and-search mechanism Slice 3 used (see C.3), not a pre-existing citation.

What is already a concrete, retail-sourced constant: the two tab control ids opened by gmVendorUI::OpenVendor0x100000B9 (Buy/browse tab) and 0x100000BB (Sell tab) — both baked directly into the executable at pc:203791/203801. These sit in the same numeric neighborhood as other already-confirmed vendor-adjacent/list-UI ids: AppraisalUiController's scrollbar/list ids (0x100000B5 horizontal scrollbar, 0x100000B6 item list, 0x10000071/ 0x10000072 decrement/increment buttons — docs/plans/2026-07-23-world-interaction-completion.md:286-289). This is circumstantial but suggestive: Turbine's UI designers assigned these ids in a contiguous block, so the vendor panel's root/child ids likely live nearby and should turn up quickly once LayoutImporter is pointed at a plausible client_local_English.dat LayoutDesc range (the same brute-force/known- neighbor search Slice 3 used to locate 0x2100006B/0x100005F2 — see the git history / research notes for that discovery if a more exact recipe is needed; it was not separately re-derived here since the concrete outcome [AppraisalUiController.LayoutId/RootId] is what matters as the precedent).

Precedent citation for "how Slice 3 found its layout id": AppraisalUiController (src/AcDream.App/UI/Layout/AppraisalUiController.cs:17-44) declares LayoutId = 0x2100006Bu, RootId = 0x100005F2u, and every other authored control as public const uint fields resolved by layout.FindElement(id) after LayoutImporter.Import(dats, LayoutId, RootId, ...) succeeds. The wiring site (src/AcDream.App/UI/RetailUiRuntime.cs:970-1068, MountAppraisal()) is the exact template to replicate for a MountVendor() method (see C.3).


C. Existing acdream seams (read-only — nothing here was modified)

C.1 — The use/interaction path a vendor-open rides on

Confirmed: vendor-open is not a new interaction path — it rides the exact same ItemInteractionControllerItemInteractionPolicy.DecideUseSendUse_requestUse reservation → J5.2's RuntimeInteractionTransactionState strict-use-gate path already used by Slice 4 (equipped-child picking) and every other useable object:

  • src/AcDream.App/UI/ItemInteractionController.cs:877-924 (ExecuteUseActions, case ItemPolicyActionKind.SendUse) routes through _requestUse(action.ObjectId, reservation) when a requestUse delegate is supplied (it always is in production wiring), which is the J5.2 strict-use-gate reservation described in docs/research/2026-07-26-slice-j5-2-interaction-transactions.md.
  • The vendor-specific hook already exists but is unwired: ItemInteractionController has a Func<uint> _activeVendorId parameter (ItemInteractionController.cs:48, 85, 120) that defaults to () => 0u and is never given a real value anywhere in the codebase (grep for activeVendorId/ActiveVendorId across src/ turns up only the declaration and the one policy consumer). ItemInteractionPolicy already uses it: if (input.ActiveVendorId != 0 && source.ContainerId == input.ActiveVendorId) return Consumed(); (ItemInteractionPolicy.cs:227-228) — i.e. "using" an item that's inside the currently-open vendor's shop (browsing/clicking a shop-list row) is swallowed as a no-op rather than sent as an ordinary Use, matching retail's UseObject short-circuit for items already inside an open shop window. This is the concrete branch point question C.1 asked about: Slice 5 needs to (a) build the vendor state owner, (b) wire its "currently open vendor guid" into activeVendorId at the ItemInteractionController construction site (currently defaulted everywhere), closing a gap that has existed since before this research.
  • PublicWeenieFlags.Vendor = 0x00000200 (ItemInteractionPolicy.cs:21) is already ported and already used for the drag-to-sell branch (SellToVendor action, DecidePlacement, ItemInteractionPolicy.cs:357-363) — that's Slice 6 territory (dragging a player item onto the vendor NPC), but confirms the flag itself is already correctly decoded from PublicWeenieDesc for any vendor NPC's CreateObject.

C.2 — Where vendor session state should live (Runtime ownership)

Recommendation: a new VendorState Core class, owned as a new child of RuntimeInventoryState, following the ExternalContainerState shape.

Reasoning, grounded in the two closeout docs:

  • docs/research/2026-07-26-slice-j4-2-inventory-state.md: RuntimeInventoryState (src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs) is the "single presentation-independent owner for the live external-container state, item-mana state, shortcut assignments, desired spell-component counts, and the retail one-inventory-request-at-a-time transaction gate." It borrows J3's ClientObjectTable (RuntimeInventoryState.cs:54, Objects => _entityObjects.Objects) and exposes each child as a property (ExternalContainers, ItemMana, Shortcuts, Transactions) plus a RuntimeInventoryOwnershipSnapshot used for reset/teardown convergence checks (RuntimeInventoryState.cs:6-31, 62-74) and a Dispose() that resets every child in a fixed order (137-161). A VendorState slots in exactly the same way: a new property, a new line in RuntimeInventoryOwnershipSnapshot, a new Try(...) call in Dispose(), and (per A.3's "reset on portal/reconnect" requirement) a new ResetVendor() method called from wherever ResetExternalContainer()/ ResetTransactions() are called today (session reset, portal-out, disconnect — the generation/lifecycle contract every other J4/J5 child follows).
  • Shape to copy: ExternalContainerState (src/AcDream.Core/Items/ExternalContainerState.cs) is the closest existing analog — "an authoritative server-driven full-replace view keyed by a requested/current id, with a Changed event for presentation observers." A vendor session is conceptually identical: request-open (fires on the Use action), apply-open (fires on ApproachVendor matching the requested id), apply-close (client-local distance/switch trigger, A.3), reset (portal/disconnect). The main structural difference from ExternalContainerState is that a vendor session also carries a profile (rates/currency/categories) and an item list, not just a container id — so VendorState is a slightly richer sibling, not a literal subclass.
  • Naming precedent: holtburger's own full-client implementation independently arrived at the exact name VendorState (references/holtburger/crates/holtburger-world/src/vendor.rs:98-108, fields: vendor_guid, items: Vec<CoreVendorItem>, buy_multiplier, sell_multiplier, merchandise_item_types, alternate_currency_wcid, alternate_currency_amount, alternate_currency_name) — a strong independent signal that this is the natural shape/name, not an acdream-specific invention.
  • J5.2's RuntimeInteractionTransactionState (docs/research/2026-07-26-slice-j5-2-interaction-transactions.md) is the natural home for request correlation if Slice 5 wants to faithfully port retail's attemptOpenVendorID token (B.1) — it already owns "last ordinary or targeted-use source/target identity" and the typed Activate/Use/Pickup FIFO. For Slice 5's browse-only scope this is optional polish (the mode-2-vs-3 tab distinction only matters once sell-drag exists in Slice 6); flagged as an open question below.

C.3 — Authored-panel infrastructure (Slice 3's precedent, to replicate)

Complete, concrete template, read end-to-end:

  1. Controller (src/AcDream.App/UI/Layout/AppraisalUiController.cs): implements IRetainedPanelController (src/AcDream.App/UI/IRetainedPanelController.csOnShown/OnHidden/ OnDescendantFocusChanged, all default no-ops; "the window manager owns visibility, focus, capture, geometry, and teardown ordering"). Declares every authored control id as a public const uint resolved later via layout.FindElement(id). Exposes a static Bind(...) factory that takes the ImportedLayout plus every Core dependency the controller needs (object table, interaction controller, selection state, etc.) and a show/close action pair, returning null if a required authored control is missing from the imported layout (defensive — the panel simply doesn't mount rather than crash).
  2. Wiring site (src/AcDream.App/UI/RetailUiRuntime.cs:970-1068, MountAppraisal()):
    • LayoutImporter.Import(dats, LayoutId, RootId, resolveSprite, defaultFont, resolveFont)ImportedLayout? (null if the LayoutDesc/root id pair isn't found in the dats — logged and bailed).
    • Any per-panel asset factories (row templates, name resolvers) loaded from the same dat lock.
    • XxxController.Bind(layout, ...dependencies..., show: () => Host.ShowWindow(WindowNames.X), close: () => CloseWindow(WindowNames.X), ...).
    • RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new RetailWindowFrame.Options { WindowName, Chrome = RetailWindowChrome.Imported, Left/Top/ContentWidth/ContentHeight = root's authored geometry, AuthoredGeometryRevision = 1, Visible = false, ResizeX/Y, MinWidth/MinHeight, ConstrainDragToParent/Resize, ContentClickThrough, Controller = controller })RetailWindowHandle. This is what gives Slice 3's window its "independent movable/resizable retail floaty" behavior and its foreground-stacking — all owned by RetailWindowFrame/Host, not by the controller.
    • Window name registered in src/AcDream.App/UI/WindowNames.cs (e.g. public const string Examination = "examination"; at line 24) — Slice 5 needs a new WindowNames.Vendor (or similar) constant.
  3. Foreground stacking / show-close semantics: Host.ShowWindow(name) / CloseWindow(name) are the only calls the controller needs; the window manager (Host) handles z-order, focus, and drag/resize bounds uniformly across every retained window. Slice 5 does not need to reinvent any of this — it needs a MountVendor() sibling to MountAppraisal().

C.4 — Inbound-message routing (where a new 0x0062 handler registers)

ACE GameEvents enter through WorldSession's decode path (GameEvents property, GameEventDispatcher, src/AcDream.Core.Net/WorldSession.cs:567) and get routed by GameEventDispatcher.Dispatch(envelope) (src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117) to whatever handler was registered for that GameEventType. The single central registration point for every handler is src/AcDream.Core.Net/GameEventWiring.csGameEventWiring.WireAll(...) (a big static method, one registrar.Register(GameEventType.X, e => { parse; apply to state; }) call per opcode, grouped by domain with comment banners // ── Chat ──, // ── Combat ──, // ── Spells ──, // ── Inventory ──, // ── Player ──).

Most recently added handler (the concrete pattern to copy): GameEventType.HouseUpdateRestrictions (0x0248) — GameEventWiring.cs:365-376, added for AP-129 (Campaign P Slice P4, 2026-07-30):

registrar.Register(GameEventType.HouseUpdateRestrictions, e =>
{
    var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span);
    if (p is null) return;
    items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
});

A new GameEventType.ApproachVendor handler follows the same shape: parse the payload with a new VendorApproach.TryParse (or similar, src/AcDream.Core.Net/Messages/), and apply it to the new VendorState owner (C.2). GameEventWiring.WireAll would need a new optional parameter (VendorState? vendor = null) matching the existing pattern used for itemMana, friends, squelch, externalContainers, etc. (GameEventWiring.cs:77-86).

C.5 — Icon/tooltip/item-row rendering reuse

SpellbookWindowController (Slice 1's spell list, src/AcDream.App/UI/Layout/SpellbookWindowController.cs) is the concrete precedent for a scrollable authored list with icons:

  • UiScrollbar (src/AcDream.App/UI/UiScrollbar.cs) is a shared, panel-agnostic primitive bound to an authored control id via layout.FindElement(scrollbarId) is UiScrollbar scrollbar (SpellbookWindowController.cs:237, 246).
  • Row icons come from a Func<uint, uint> resolveXxxIcon delegate injected at construction (resolveSpellIcon, resolveComponentIconSpellbookWindowController.cs:53-54, 89-90), used as CatalogIconTexture = _resolveSpellIcon(spellId) (SpellbookWindowController.cs:291) against a _rowStyle (SpellbookRowStyle) describing icon geometry (IconLeft/Top/ Width/Height, SpellbookWindowController.cs:299-302).
  • Since retail materializes each vendor item as a full weenie object with a real PublicWeenieDesc (icon dat id included — A.2 point 4), a vendor item row can resolve its icon exactly the way the inventory panel resolves any ordinary item's icon (by the item's own IconId field, already parsed by the shared CreateObject/PublicWeenieDesc body parser — see the note in the next paragraph), not by a spell-specific resolver. This is a materially easier reuse case than the spell list, since vendor items are ordinary items once parsed.

One structural gap worth flagging as a contract decision, not just reuse: src/AcDream.Core.Net/Messages/CreateObject.cs (TryParse, line 512 onward) parses the entire WeenieHeader body inline as one large method (opcode → guid → model data → physics data → weenie-header fixed prefix at ~line 781 → weenie-header optional tail at ~line 834 onward, using the same bit flags as Chorizite's PublicWeenieDesc.generated.cs). It is not currently factored into a reusable standalone function callable from a new vendor-item parser. Since A.2 established that each ItemProfile entry uses the exact same PublicWeenieDesc body (just without the preceding model/physics data), the clean move is to extract the weenie-header-body parsing logic (~CreateObject.cs:781 to the end of TryParse) into a shared static helper both CreateObject.TryParse and the new vendor-item parser call, rather than re-deriving/duplicating ~300 lines of conditional-field parsing. This is flagged explicitly as an open question for the contract (below) since it's a nontrivial refactor decision, not a pure research finding.


D. Out-of-scope fence — what Slice 6 owns

Per docs/plans/2026-07-23-world-interaction-completion.md:56-57:

| 6 | Vendor transactions | server-authoritative buy/sell command and reconciliation owner |

"Vendor buy/sell transactions, quantities, pending-state ownership, and authoritative inventory reconciliation complete the loop."

Concretely, Slice 6 (not Slice 5) owns:

  • Outbound buy/sell wire messages. ACE: HandleActionBuyItem (Player_Commerce.cs:22-52) / HandleActionSellItem (Player_Commerce.cs:126-226), each taking a List<ItemProfile> (quantities + guids). Retail: CM_Vendor::Event_Buy (0x006AA0F0) / ::Event_Sell (0x006AA000).
  • Quantity/stack-split selection UI (VendorBuyUI/VendorSellUI sub-panels, stack-slider interaction — gmVendorUI::RecvNotice_StackSliderChanged in the symbol table).
  • Drag-to-sell — dragging a player inventory item onto the vendor NPC (ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor, ItemInteractionPolicy.cs:357-363, already ported but its dispatch/UI consumption is Slice 6's to wire).
  • VendorProfile::InqAcceptability/IsAcceptable (sell-eligibility filtering by item type/value/magic — pc:484768-484797) — this is a judgment call flagged below, since "does my inventory show which items this vendor will buy" arguably touches Slice 5's "supports retail selection/browsing" language too.
  • Currency/pyreal math validation, Vendor.GetSellCost/GetBuyCost as an authoritative transaction input (Slice 5 only needs the display computation — see B.1 — not the transaction-time application).
  • Pending-sale correlation (attemptOpenVendorID/attemptSaleObjectID, the mode-2-vs-3 tab-switch nuance from B.1) — Slice 5's browse-only scope can default to always opening the Buy/browse tab and defer full request correlation to Slice 6, since the correlation token only matters once a sell-drag exists.
  • ApproachVendor refreshes triggered by FinalizeBuyTransaction (Player_Commerce.cs:112) and ProcessItemsForPurchase (Vendor.cs:661) — Slice 5's parser/state owner should be built to handle repeat ApproachVendor events correctly (A.3, full-replace semantics), but the actual triggers for those repeats are Slice 6 actions.
  • Authoritative inventory reconciliation after a purchase/sale lands (new items appearing in the player's pack, coins deducted/added) — this is standard InventoryPutObjInContainer/ViewContents/property-update plumbing already owned by existing Slice 4/J4.2 machinery, but triggered by Slice 6's buy/sell actions.

Open questions for the contract

  1. Does VendorState live in AcDream.Core.Items (next to ExternalContainerState) or a new AcDream.Core.Commerce/Vendor namespace? Recommendation: AcDream.Core.Items — it's small, it directly parallels ExternalContainerState, and it avoids a near-empty new namespace for one class family. Weak preference; either is defensible.
  2. Should the shared PublicWeenieDesc-body parser be extracted from CreateObject.TryParse into a standalone reusable method before Slice 5 writes the vendor-item parser, or should Slice 5 duplicate/adapt a subset first and defer the refactor? Recommendation: extract first — duplicating ~300 lines of conditional-flag parsing for the second time this project has needed it is exactly the kind of drift the project's "grep named → decompile → verify → port" discipline exists to prevent, and a parser bug fixed in one copy but not the other is a classic two-owners bug.
  3. Does Slice 5 port ShopSystem::BuyPrice/SellPrice as a pure Core function now (needed to show a display price in the browse list), or is showing raw item value acceptable for a browse-only slice with actual price math deferred to Slice 6? Recommendation: port it now — it's a ~10-line pure function with a byte-verified formula (B.1), and a vendor list that shows the wrong price (or no price) is a visibly broken browse experience even before Buy is wired up.
  4. Does Slice 5 wire the attemptOpenVendorID-style request-correlation token (mode 2 vs 3 tab selection), or hardcode "always open on the Buy/browse tab" and let Slice 6 add correlation when sell-drag lands? Recommendation: hardcode Buy/browse tab for Slice 5; the correlation only has externally-visible effect once a sell-initiated open exists.
  5. Does "supports retail selection/browsing" (the Slice 5 charter language) include showing which of the player's own inventory items this vendor would accept (via VendorProfile::InqAcceptability), or is that squarely Slice 6 since it's meaningless without the sell action attached? Recommendation: defer to Slice 6 — InqAcceptability has no purpose without a sell UI to gate, and pulling it into Slice 5 would mean touching the sell sub-panel's territory for a browse-only slice.
  6. Category/type filter tabs (VendorItemsUI::AddTypeFilter/ ListContainsType) were located but not read in full decompiled detail in this pass (time-boxed). If the Slice 5 contract wants exact retail parity for tab filtering (vs. a single flat browsable list for a first cut), a follow-up grep-and-read pass on pc: lines around 0x004C05C0/0x004C0D90 is needed before implementation.
  7. The vendor panel's own top-level LayoutDesc id is not yet dat-extracted anywhere in the repo. The contract should budget time for a LayoutImporter.Import discovery pass (the same process that found 0x2100006B for the examination window) rather than assuming the id is already known. The two concrete tab-control ids (0x100000B9 Buy, 0x100000BB Sell) are known and can serve as a cross-check once a candidate root/LayoutDesc id is found (their FindElement should succeed under the correct root).
  8. docs/architecture/retail-divergence-register.md row AP-110 currently lists "vendor/trade/salvage/tinkering" together as absent panels. Per the project's binding divergence-register rule, Slice 5's landing commit must narrow AP-110 to drop "vendor" from the absent list (or add a precise successor row describing what remains absent — e.g. category filter tabs if #6 above is deferred) in the same commit that lands vendor browsing.

§B.4 — D0 tab-filter read, 5.4 (2026-08-09)

Mandatory pre-UI read for Slice 5.4 (contract decision 6), grepping VendorItemsUI::AddTypeFilter (0x004C05C0) / ::ListContainsType (0x004C0D90) plus their caller VendorItemsUI::OpenVendor (0x004C16D0) and the whole-panel gmVendorUI::OpenVendor/PostInit/constructor (0x004C4BA0/0x004C09A0/0x004C2470). This also resolves contract decision 7 (layout-id discovery) — the two findings are entangled since the D0 read is what proves the layout structure below.

Layout identity: LayoutDesc 0x21000012, root 0x100000B7

Found via brute-force enumeration of every LayoutDesc in client_local_English.dat (dats.GetAllIdsOfType<LayoutDesc>(), 101 entries) for the one whose descendant tree contains BOTH known tab-control ids 0x100000B9 and 0x100000BB as siblings. Exactly one match. Cross-check per contract decision 7: UIElement::RegisterElementClass(0x10000017, gmVendorUI::Create) (pc:202075) — root 0x100000B7's own resolved Type is 0x10000017, the literal retail class id for gmVendorUI itself. This is not circumstantial; it is the direct proof the root element found IS gmVendorUI's own instantiated root.

Root geometry: 800x110 at design position (0,500) inside an 800x600 canvas — a compact bottom-docked strip, the same shape family as ExternalContainerController's 0x21000008/0x10000063 (not a large grid+icon browse window; AC's vendor UI is a horizontal single-row icon strip, matching general retail recollection).

Method: a throwaway scanner tool (tools/VendorLayoutScan/, not part of the shipped solution) using DatCollection.GetAllIdsOfType<LayoutDesc>() + recursive ElementDesc.Children search, then LayoutImporter.ImportInfos(dats, layoutId, rootId) (the SAME resolved inheritance pipeline production uses) to dump full resolved Type + StateDesc.Properties per element, including DatStringResolver-resolved StringInfo labels (property 0x17). This is what nailed the exact tab→page mapping below with dat-authored-label proof, not inference.

Tree shape (verified via the resolved-property dump, not just raw ElementDesc)

0x100000B7  gmVendorUI root (Type 0x10000017), 800x110 @ design (0,500)
├─ 0x100000D6  close/pushpin button (Type 1, icon-only, no label — plain X)
├─ 0x100000B8  m_vendorPanel (Type 0x8 = UIElement_Panel; PostInit binds
│              this via GetChildRecursive(this, 0x100000b8))
│  ├─ 0x100000B9  tab, label "Items"    (order 1, x=0)
│  ├─ 0x100000BA  tab, label "Buying"   (order 2, x=92)
│  ├─ 0x100000BB  tab, label "Selling"  (order 3, x=184)
│  ├─ 0x100000BC  page for tab B9 ("Items") == VendorItemsUI's content
│  │  ├─ 0x100000BD  m_shopList (Type 0x10000031 UiItemList, 710x32 —
│  │  │              cell 32x32 from base 0x2100003D/0x10000339 ⇒ ~22-slot
│  │  │              single-row horizontal strip, matches
│  │  │              ExternalContainerController's list shape)
│  │  ├─ 0x100000BE  scrollbar (Type 0xB, horizontal, 710x16)
│  │  ├─ 0x100000BF  m_itemTypeMenu (Type 0x6 = UIElement_Menu, resolves
│  │  │              to acdream's UiMenu via DatWidgetFactory's `6 =>
│  │  │              new UiMenu()` — a FULLY IMPLEMENTED dropdown widget
│  │  │              already, Items/Selected/OnSelect/ButtonLabelProvider)
│  │  ├─ 0x100000C0  m_itemNameText
│  │  ├─ 0x100000C1  m_itemCostText
│  │  ├─ 0x100000C2  m_buyButton, label "Buy"
│  │  └─ 0x100000C3  m_addButton, label "Add to List"
│  ├─ 0x100000C4  page for tab BA ("Buying") == VendorBuyUI's content
│  │  ├─ 0x100000C5  m_buyShopList (staged-to-buy list, NOT the shop's
│  │  │              full stock — see below)
│  │  ├─ 0x100000C6  scrollbar
│  │  ├─ 0x100000C7/C8  m_buyListText / m_buyPurseText
│  │  ├─ 0x100000C9  m_buyItemButton, label "Buy Item"
│  │  ├─ 0x100000CA  m_buyAllButton, label "Buy All"
│  │  └─ 0x100000CB/CC  m_buyClearItemButton "Clear Item" /
│  │                    m_buyClearListButton "Clear List"
│  └─ 0x100000CD  page for tab BB ("Selling") == VendorSellUI's content
│     ├─ 0x100000CE  m_sellShopList (staged-to-sell list)
│     ├─ 0x100000CF  scrollbar
│     ├─ 0x100000D0/D1  m_sellListText / m_sellPurseText
│     ├─ 0x100000D2  m_sellItemButton, label "Sell Item"
│     ├─ 0x100000D3  m_sellAllButton, label "Sell All"
│     └─ 0x100000D4/D5  m_sellClearItemButton / m_sellClearListButton,
│                       labels "Clear Item" / "Clear List"
└─ 0x1000008D  title/backdrop (Type 0x8, ZLevel 100 — drawn behind)

Every id above is confirmed two ways: (1) VendorItemsUI::VendorItemsUI (pc:199612)/VendorBuyUI::VendorBuyUI (pc:199717)/ VendorSellUI::VendorSellUI (pc:199753) bind these EXACT child ids via UIElement::GetChildRecursive; (2) the resolved-property dump shows the matching 0x17 StringInfo label on each button, so the id↔label pairing is read directly off the dat, not inferred from ctor ordering alone.

Tab↔page semantics — corrects the contract's 2-tab assumption

The contract (decision 4, written before this read) assumed two tabs: "browse/Buy" (0x100000B9) and "Sell" (0x100000BB). The dat actually authors three tabs, and the dat-resolved labels settle their meaning precisely — this is NOT the Buy/Sell MODE switch the contract assumed, it is Items/Buying/Selling, three independent staging views:

  • "Items" (0x100000B9)VendorItemsUI: the vendor's full stock, filterable by category, each row clickable, with Buy/Add to List buttons. This is Slice 5.4's browse view — the only page with useful content before Slice 6 exists.
  • "Buying" (0x100000BA)VendorBuyUI: review/confirm panel for gmVendorUI.m_buyList, a CLIENT-side staging list of items you've chosen to buy from the Items tab (m_buyItemButton/m_buyAllButton on the Items page add to it; this tab's own buttons commit/clear it). Empty on a fresh open (m_buyList starts empty — confirmed by gmVendorUI::OpenVendor's unconditional CloseVendor on any previous session before rebuilding). Slice 6 territory.
  • "Selling" (0x100000BB)VendorSellUI: the symmetric staging view for items dragged from your own inventory onto the vendor (ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor, already ported, Slice 6 wires its UI consumption). Matches contract decision 4's "Sell tab... stays inert" almost exactly — its authored label is "Selling," not "Sell," but the semantic match is exact.

gmVendorUI::OpenVendor (pc:203650) confirms all three sub-panels (m_itemsUI, m_buyUI, m_sellUI) refresh unconditionally on every ApproachVendor (this->m_itemsUI->vtable->OpenVendor(...); m_sellUI->OpenVendor(...); m_buyUI->OpenVendor(...), pc:203852-203854), regardless of which tab ends up visually open — only the VISIBLE tab is mode-dependent (OpenTab(m_vendorPanel, 0x100000b9) for mode 2, 0x100000bb for mode 3, pc:203791/203801mode 2 opens tab 0x100000B9 ("Items"), confirming decision 4's "browse/Buy tab" language was describing this exact tab, just informally — its authored name is "Items," not "Buy"). Slice 5.4 therefore: mounts all three tabs (matching the authored layout and RetailTabBinding's existing tri-state pattern precedent, SpellbookWindowController's Spell/Component tabs), defaults to "Items" selected/visible, and leaves "Buying"/"Selling" as present-but-unpopulated pages (no VendorBuyUI/VendorSellUI port this slice — both are squarely Slice 6, matching the contract's "no buy/sell actions" fence). This symmetric treatment — not just the contract's originally-anticipated single "Sell" tab — gets the "comment the fence" treatment for both non-default tabs.

Category/type filter mechanism (the actual D0 answer)

VendorItemsUI::AddTypeFilter(this, label, typeMask) (pc:199667) does not create a visible tab button — it calls UIElement_Menu::InsertTextItem(m_itemTypeMenu, label, m_numTypeFilters), i.e. it inserts a row into the dropdown menu at 0x100000BF, tagging the new menu item with the type mask via a per-item property (BaseProperty::SetPropertyName(&prop, 0x10000039) then storing typeMask into it). "Category tabs" in the contract's phrasing means this dropdown, not additional tab buttons.

VendorItemsUI::OpenVendor (pc:200779-201025) rebuilds the dropdown from scratch on every open/refresh: flushes m_shopList and m_itemTypeMenu, then walks an ordered, hardcoded 18-entry table, calling ListContainsType(shopItemProfileList, mask) for each and only adding the filter (AddTypeFilter) if the vendor's stock contains a matching item. ListContainsType (pc:200132-200159) is a linear scan: true iff any shop item's InqType() & mask != 0. Table, in authored order, with the acdream ItemType composite that reproduces each literal retail mask bit for bit (verified against src/AcDream.Core/Items/ClientObject.cs:26-75 — every bit accounted for, no gaps):

# Label Retail mask acdream ItemType
1 Armor 0x2 Armor
2 Books, Paper 0x2000 Writable
3 Clothing 0x4 Clothing
4 Containers 0x200 Container
5 Food 0x20 Food
6 Gems 0x800 Gem
7 Jewelry 0x8 Jewelry
8 Keys, Tools 0x20004000 TinkeringTool | Key
9 Miscellaneous 0x490 Useless | Misc | Creature
10 Services 0x100000 Service
11 Spell Components 0x1000 SpellComponents
12 Trade Notes 0x40000 PromissoryNote
13 Weapons 0x101 Weapon (existing composite)
14 Mana Stones 0x80000 ManaStone
15 Magic Items 0x8000 Caster
16 Alchemical Items 0x4800000 CraftAlchemyIntermediate | CraftAlchemyBase
17 Cooking Items 0x400000 CraftCookingBase
18 Fletching Items 0x9000000 CraftFletchingIntermediate | CraftFletchingBase

After rebuilding, retail selects an index (pc:201008-201022): the PREVIOUS selected index if it's still < newCount - 1 inclusive-clamp, otherwise clamps to newCount - 1, then floors at 0. Net effect: first open (GetSelectedIndex returns -1, nothing selected yet) always lands on index 0 — the FIRST present category in table order, not "show everything." Pseudocode:

selected = previousSelectedIndex        // -1 on first open
if (selected >= presentCount - 1) selected = presentCount - 1
if (selected < 0) selected = 0

VendorItemsUI::UpdateItemsList(this, mask, notify) (pc:201029 on) confirms the filter is load-bearing, not cosmetic: it re-walks the FULL shopItemProfileList and inserts into m_shopList only items where (activeMask & itemType) != 0, where activeMask is either the explicit mask argument (arg2 != 0) or, when arg2 == 0, the CURRENTLY selected menu item's stored 0x10000039 property. There is no "all types" state — an empty/no selection filters everything out (0 & anything == 0), which is exactly why retail always force-selects index 0 after rebuilding. Slice 5.4 ports this literally: the item list is always scoped to exactly one category; clicking a different dropdown entry re-filters via the stored mask on that entry, matching UpdateItemsList's arg2 != 0 explicit-mask branch.

Verdict: bounded, in scope, no fallback needed

The full mechanism is an 18-row static table + a linear membership test reused per row + one dropdown-population pass + a selection-index clamp — small and precedented (UiMenu is a complete pre-existing widget; SpellbookWindowController's FilterButtons array is the same "static mask table drives filterable UI" shape, just buttons instead of a dropdown). This does not trigger the "mechanism too large — STOP" clause; the flat-list fallback is not needed. The genuinely new information this read surfaced beyond the contract's decisions — three tabs instead of two, and the dropdown-vs-tab distinction for "category tabs" — is recorded above for the record, not treated as a scope escalation.