acdream/docs/research/2026-08-08-slice6-vendor-transactions-research.md
2026-08-07 19:35:21 +02:00

44 KiB
Raw Permalink Blame History

Slice 6 research — vendor transactions (buy focus)

Date: 2026-08-08 Trigger: live user report — "I cant buy anything. Nothing happens when I double click or when I press buy. I dont get a slider for items that stack and when I select an item it does not show in the status bar as selected." Scope: the buy round-trip (wire, retail mechanism, acdream seams) plus the three UI symptoms behind it. Sell is noted where the wire is a trivial mirror of buy; full sell UI/staging is out of scope per the Slice 5 fence and stays out here.

Verified starting point: repo HEAD at research time was e602f84b ("fix(ui): Slice 5.4 review corrections..."), at/after the required e602f84b gate. Read-only research; no code changed.

Mandatory prior reading done first: docs/research/2026-08-08-slice5-vendor-browse-research.md, including §B.4 (the D0 tab-filter read that discovered the LayoutDesc tree, the Items/Buying/Selling three-tab shape, and the category dropdown) and its §D Slice-6 fence. This document does not re-derive anything already pinned there — it cites forward to it instead. The current on-disk implementation (src/AcDream.App/UI/Layout/VendorUiController.cs, src/AcDream.Core/Items/VendorState.cs) already reflects that doc's findings and register row AP-161 (docs/architecture/retail-divergence-register.md) already names the exact four residuals this document expands into an implementation-ready shape.


Executive summary

All four user-reported symptoms trace to one missing wiring step, not four separate bugs:

  1. Buy button does nothingVendorUiController builds _buyButton but never assigns .OnClick (VendorUiController.cs:211, 368-369; confirmed by reading the whole file — no _buyButton.OnClick = assignment exists anywhere).
  2. Double-click does nothing — there is no double-click handler at all; cell.Clicked (VendorUiController.cs:567) is the ONLY interaction a row has, and it only calls the PRIVATE SelectItem.
  3. No slider for stackable items — the real slider machinery (SelectedObjectController, StackSplitQuantityState) already exists and is fully built, but VendorUiController never touches it; it computes its own local VendorSplitSize(item) for display only (VendorUiController.cs:611-651).
  4. Selection doesn't show in the status barVendorUiController keeps a PRIVATE _selectedItemGuid field (VendorUiController.cs:216, 611-636) instead of driving the real global selection owner (AcDream.Core.Selection.SelectionState) that the status bar (SelectedObjectController) actually reads.

All three UI symptoms (24) collapse into "VendorUiController never touches SelectionState/StackSplitQuantityState, the same shared owners Toolbar/Radar/Inventory/ExternalContainer/Magic already use" — confirmed by direct comparison of VendorRuntimeBindings (2 fields: State, ResolveIcon) against every sibling *RuntimeBindings record, every one of which carries a SelectionState Selection field (src/AcDream.App/UI/RetailUiRuntime.cs:41-143). Vendor is the outlier.

Symptom 1 (Buy) is a genuinely separate, additional gap: no outbound wire message exists yet at all. The retail mechanism, wire shape, and acdream's existing builder pattern for it are all fully pinned below.


A. The wire (buy + the reconciliation that follows)

A.1 — Buy opcode and payload

GameActionType.Buy = 0x005Freferences/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:51. Handler: GameActionBuyItems.Handlereferences/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionBuyItems.cs:9-35.

Wire payload, confirmed four ways (ACE's reader, Chorizite's generated reader/writer, holtburger's independent client implementation, AND the retail decompiled sender — all agree byte-for-byte):

u32   0xF7B1              // GameAction envelope (acdream: InteractRequests.GameActionEnvelope)
u32   gameActionSequence
u32   0x005F               // Buy opcode
u32   vendorGuid
u32   itemCount
  per item:
  i32   amount             // quantity to buy (NOT the packed sign-extended
                            // supply-count field from ApproachVendor — a
                            // plain positive int32)
  u32   objectGuid          // the SHOP ITEM's guid (from ApproachVendor's
                            // ItemProfile list, A.2 of the Slice 5 doc)
u32   alternateCurrencyId  // 0 for a pyreal vendor; the vendor's own
                            // AlternateCurrency wcid otherwise
  • ACE reader (server-authoritative): GameActionBuyItems.Handle reads vendorGuid (u32), numItems (u32), then per item amount (i32) then objectID (u32) — Actions/GameActionBuyItems.cs:12-27. It reads NO trailing currency field — the line is present but commented out: //var altCurrencyWcid = message.Payload.ReadUInt32(); (line 32).
  • Chorizite.ACProtocol (clean-room generated, cross-check): Vendor_Buy writes ObjectId (u32), a PackableList<ItemProfile> (count + items), then AlternateCurrencyId (u32) — references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Vendor_Buy.generated.cs:33-48. Its ItemProfile type (Types/ItemProfile.generated.cs) is the SAME type used for the S2C ApproachVendor item list, but for an outbound Buy the high byte of PackedAmount is always 0 (small positive quantities never set bits 24-31), so PwdType resolves to 0 and the switch falls through without reading a PublicWeenieDesc body — this is why ACE's simpler 2-field-per-item read and Chorizite's reused-type read agree despite looking different at first glance.
  • holtburger (independent full client, most authoritative for "what a real client sends"): BuyActionData { vendor_guid, items: Vec<ItemProfileActionData{amount: i32, object_guid: Guid}> }references/holtburger/crates/holtburger-protocol/src/messages/trade/actions.rs:34-65, packed with vendor_guid.pack(); items.len() as u32; per-item amount then guid (lines 56-65). holtburger's pack has NO trailing currency field at all — confirmed by its own round-trip test fixture (actions.rs:296-310, 16 bytes total for guid+count+one item, nothing after).
  • Retail decompiled sender — the deciding vote. CM_Vendor::Event_Buy (pc:689288, 0x006AA0F0), signature Event_Buy(uint32_t vendorGuid, PackableList<ItemProfile> const* items, IDClass<_tagDataID,32,0> currencyId) — the mangled name (?Event_Buy@CM_Vendor@@YA_NKABV?$PackableList@VItemProfile@@@@V?$IDClass@U_tagDataID@@$0CA@$0A@@@@Z) independently confirms the 3-argument shape. Reading the body: writes opcode 0x5f (pc:689300), arg1 (vendorGuid, pc:689303), the packed item list (arg2->vtable->Pack(...), pc:689335), then *(uint32_t*)var_c = arg3; (pc:689336) — the currency id IS written after the item list, every time, by the real client. By contrast CM_Vendor::Event_Sell (pc:689229, 0x006AA000) has only a 2-arg signature (vendorGuid, items) and its body (pc:689229-689242+) never writes a trailing field — Sell truly has no currency suffix, Buy does.

Resolution of the ACE/holtburger vs. retail disagreement: retail (the top oracle per CLAUDE.md) and Chorizite both show a real client DOES send a trailing u32 currency id on Buy. ACE's current server build ignores it (commented-out read) and holtburger — a real client written against a server contract, i.e. tested for what ACE accepts — omits it entirely and still works against ACE. Recommendation for the contract: port the field for retail fidelity (it costs one u32, matches the byte-verified retail sender, and is forward-compatible with any future ACE version that un-comments its read), but do not treat its absence as a functional risk — ACE demonstrably doesn't require it today (holtburger's own round-trip tests pass against ACE without it). Cite this exact tension in the outbound builder's doc comment so a future reader doesn't "fix" it either way without re-reading this note.

Sell (research-only, for Slice 6 scoping — not implemented this pass): GameActionType.Sell = 0x0060GameActionType.cs:52. Handler GameActionSellItems.Handle reads vendorGuid, numItems, then per item amount(i32)/objectGuid(u32) — no trailing currency field, matching retail's 2-arg Event_Sell and Chorizite's Vendor_Sell (no AlternateCurrencyId member at all, Messages/C2S/Actions/Vendor_Sell.generated.cs) and holtburger's SellActionData (trade/actions.rs:68-97).

A.2 — What the server sends back

Success path, traced through Player.HandleActionBuyItemVendor.BuyItems_ValidateTransaction (on success) → Player.FinalizeBuyTransaction (references/ACE/Source/ACE.Server/WorldObjects/Player_Commerce.cs:22-116, Vendor.cs:431-571), IN ORDER:

  1. SpendCurrency → for a pyreal vendor, destroys pyreal stacks via TryConsumeFromInventoryWithNetworking which sends GameMessageInventoryRemoveObject/GameMessageSetStackSize (whichever applies) then UpdateCoinValue(), which sends GameMessagePrivateUpdatePropertyInt(PropertyInt.CoinValue, ...) only if the value actually changed (Player_Commerce.cs:319-333, Player_Inventory.cs:141-146).
  2. Per purchased item: TryCreateInInventoryWithNetworking (ordinary item-into-inventory placement — GameMessageCreateObject + GameEventItemServerSaysContainId + GameMessagePrivateUpdatePropertyInt(EncumbranceVal), the same generic path every other "item enters your pack" flow uses — Player_Inventory.cs:105-107).
  3. GameMessageSound(PickUpItem).
  4. vendor.ApproachVendor(this, VendorType.Buy, altCurrencySpent) — a fresh, full-replace ApproachVendor (0x0062), confirming the Slice 5 doc's §A.3 finding that a buy always ends in the SAME full-snapshot-replace event Slice 5 already parses (this is a VendorStateTransitionKind.Refreshed in acdream's VendorState.Apply, src/AcDream.Core/Items/VendorState.cs:144-182 — no new state-machine case needed, only a trigger).
  5. Back in HandleActionBuyItem: SendUseDoneEvent() (no error code) — always fires last, on BOTH the validation-failed and the validation-succeeded branch (Player_Commerce.cs:47-49; the "failed" branch only additionally enqueues GameEventInventoryServerSaveFailed first).

Failure paths, all confirmed in Vendor.BuyItems_ValidateTransaction (Vendor.cs:431-571) and HandleActionBuyItem:

Cause What's sent Notes
IsBusy / IsTrading / vendor not found (pre-checks in HandleActionBuyItem) GameEventInventoryServerSaveFailed(Guid.Full) + SendUseDoneEvent(WeenieError.X) then return (no further UseDone) Guid.Full here is the player's own guid, not an item guid — see the acdream cross-reference below
Invalid amount in any item player.SendTransientError("Invalid amount") then false client-visible chat/transient string
Insufficient pack space / burden / container slots GameEventCommunicationTransientString(...) (one of three specific sentences) then false
Insufficient currency (the common case) nothing at all — silent return false (Vendor.cs:546-563) no transient string, no error code; the ONLY signal downstream is step 6 below
Any of the above false returns back in HandleActionBuyItem: GameEventInventoryServerSaveFailed(Guid.Full) is enqueued, THEN the unconditional SendUseDoneEvent() (no error) fires at the bottom the failure path and the pre-check-rejection path both use GameEventInventoryServerSaveFailed, but the pre-check path ALSO sets a WeenieError on UseDone; the validation-failure path's UseDone carries no error

acdream cross-reference — a wire nuance for the contract to know about up front: acdream ALREADY parses and handles GameEventInventoryServerSaveFailed (0x00A0) — src/AcDream.Core.Net/Messages/GameEvents.cs:445-456, wired at src/AcDream.Core.Net/GameEventWiring.cs:479-489 for the B-Drag optimistic-inventory-move rollback path (InventoryActions.cs:34). That existing handler is written around the assumption that ItemGuid names a speculative LOCAL inventory operation it can roll back. For a buy failure, ACE sends this SAME event with ItemGuid = the player's own guid (Guid.Full inside Player, not an item) — the existing handler will look up the player's guid in whatever rollback table it tracks, find nothing to roll back (a harmless no-op), and log through its existing [B-Drag] InventoryServerSaveFailed ... diagnostic line (GameEventWiring.cs:489). This is not a blocker — the event already being parsed and routed means Slice 6 does not need to add a new parser — but whoever wires Buy's failure path should not be surprised to see a [B-Drag] log line fire on a failed purchase; it is retail-authentic wire behavior (ACE truly reuses the same event), not a bug in the existing handler.

A.3 — Sell (research only, confirming the fence)

Player.HandleActionSellItem (Player_Commerce.cs:126-226) is a full mirror shape: per-item validation via VerifySellItems, payout calculation via Vendor.CalculatePayoutCoinAmount/GetBuyCost, pack-space check, item removal (TryRemoveFromInventoryWithNetworking/ TryDequipObjectWithNetworking + GameEventItemServerSaysContainId), vendor.ProcessItemsForPurchase, coin-stack creation (TryCreateInInventoryWithNetworking), GameMessageSound, and a final unconditional SendUseDoneEvent(). No new findings beyond confirming the Slice 5 doc's fence — this stays Slice 6b/out-of-scope-for-this-pass territory; the buy-side plumbing recommended below (wire builder pattern, UseDone gate, ApproachVendor refresh handling) is directly reusable for sell once the sell UI exists.

A.4 — How the buy round-trip maps onto J5.2's transaction gate

UseDone is the completion signal — not the money update, not the ApproachVendor refresh. Three independent confirmations:

  1. ACE: every code path through HandleActionBuyItem ends in exactly one SendUseDoneEvent() call, success or failure (A.2 above) — it is structurally the terminal event of the request, the same as ordinary Use.
  2. holtburger models Buy/Sell as BusyOperationKind::Buy/::Sell, armed via the SAME arm_busy_operation single-flight gate ordinary Use/UseWithTarget use (references/holtburger/crates/holtburger-core/src/client/commands.rs:526-545, 430-438), and its own tests prove completion fires on GameEvent::UseDone (references/holtburger/crates/holtburger-core/src/client/mod.rs:646-680, and the commands.rs:2131-2195 integration tests) — never on a money or ApproachVendor event.
  3. acdream already has the exact matching gateRuntimeInteractionTransactionState (src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs) owns BeginUseRequestReservation()/TryDispatchUse(...) (the single-flight "one request outstanding" gate) and CompleteUse(uint error) (line 215-222), which is ALREADY wired to the inbound UseDone (0x01C7) handler (src/AcDream.Core.Net/GameEventWiring.cs:492-503, 497: registrar.Register(GameEventType.UseDone, e => { ... onUseDone?.Invoke(err.Value); ... })).

Recommendation: Buy dispatch should call BeginUseRequestReservation()/the equivalent reservation flow exactly the way ItemInteractionController.ExecuteUseActions's SendUse case does today (Slice 5 doc §C.1), and let the SAME existing UseDone handler resolve it via CompleteUse. No new completion-signal plumbing is needed on the receive side — only the send side (a new outbound builder) and a new dispatch call site that goes through the existing gate need to be added. This also automatically gives Buy retail's IsBusy semantics for free: the existing single-flight gate already rejects a second Use/Buy while one is outstanding, matching ACE's own IsBusy check (Player_Commerce.cs:24-28).


B. Retail client mechanism

B.1 — What the Buy and Add buttons do

Read gmVendorUI::HandleButtonClicks (pc:203950, 0x004C50D0) in full — this is the dispatcher for every button on the vendor panel, keyed by the authored element id (all four ids below are confirmed against the Slice 5 doc's §B.4 layout tree — 0x100000C2=Buy, 0x100000C3=Add to List on the "Items" tab; 0x100000C9/CA=Buy Item/Buy All on the "Buying" tab):

  • 0x100000C2 (Buy button, "Items" tab)gmVendorUI::BuySingleItem(this, ACCWeenieObject::selectedID) (pc:203967). This is an IMMEDIATE single-item purchase of whatever is currently globally selected — it does NOT stage into the "Buying" tab. Reading BuySingleItem itself (pc:201661, 0x004C2820) in full:
    • Reads the selected item's own _stackSize; if <=1 uses quantity 1, else calls ItemHolder::GetObjectSplitSize (i.e. the CURRENT slider value) as the purchase quantity (pc:201674-201681).
    • Computes the price via VendorProfile::VendorSellPrice and does a client-side affordability pre-check against this->m_totalValue (pyreal holdings) or, for an alt-currency vendor, shopVendorProfile->trade_num - m_last_sale (pc:201686-201717); on failure it shows a LOCAL string via ECM_UI::SendNotice_DisplayStringInfo and returns WITHOUT sending anything to the server (pc:201700-201712).
    • Also does a client-side pack/container-capacity pre-check (pc:201730-201746) mirroring ACE's own server-side check.
    • On success: builds a ONE-entry ItemProfile list (var_9c = <split size>, var_98 = <selected guid>, pc:201750-201756), calls CM_Vendor::Event_Buy(shopVendorID, &list, currencyId) (pc:201763 — the exact wire builder traced in A.1), records the request (ACCWeenieObject::RecordRequest(shopVendorID, IR_SHOP_EVENT)) and increments a client-local busy counter (ClientUISystem::IncrementBusyCount) — the client-side mirror of the server's IsBusy gate (pc:201764-201765).
  • 0x100000C3 (Add to List button, "Items" tab) → looks up the selected weenie, computes its split size the same way, then calls VendorItemsUI::AddToBuyList(this->m_itemsUI, item, quantity) (pc:203971-203985). This stages the item into this->m_buyList (the data backing the "Buying" tab) — it sends NOTHING to the server.
  • 0x100000C9 ("Buy Item" button, "Buying" tab) — buys the currently selected item WITHIN the staged buy list (calls the same BuySingleItem), then removes it from the staged list on success (pc:203989-204009).
  • 0x100000CA ("Buy All" button, "Buying" tab) — the batch path: validates the WHOLE staged list's total cost against holdings/container capacity, then calls gmVendorUI::SendShopEvent(this, shopVendorID, &this->m_buyList, currencyId, SE_BUY) (pc:204075) — sends the ENTIRE staged list in one Event_Buy call, then flushes the staged list (pc:204076-204077).

Conclusion for the contract: retail's Buy button is a real, self-contained, immediate single-item purchase path that does NOT require the "Buying" tab/staging list to exist at all — BuySingleItem only reads ACCWeenieObject::selectedID and the shared split-size state, both of which are global, not staging-list-local. This directly unblocks a minimal Slice 6: the Buy button can be wired to send a real purchase without building VendorBuyUI/staging first. The Add button, by contrast, genuinely requires the "Buying" tab's staging list to exist to have any effect — it's pure client-local UI state with no wire message, so it can be safely left unwired (as it already is) without any user-visible "does nothing wrong" surprise, matching the register's existing framing of the "Buying" tab as present-but-inert.

B.2 — Double-click

No dedicated double-click-to-buy mechanism was found for vendor shop items. Evidence, not absence-of-search:

  • gmVendorUI::ListenToElementMessage (pc:204260-204309, full function read) dispatches on message id 1 (button click → HandleButtonClicks), 7 (dropdown selection change), 0x2c (page change), 0x15 (drop release), and 0x1c (routes to HandleMousePresses only when m_itemsUI != 0) — there is no distinct "double-click" message id handled at the panel level.
  • The base list class UIElement_ItemList (every method enumerated via docs/research/named-retail/symbols.json, ~50 symbols) has HandleSingleSelection, HandleTargetedUseLeftClick, ItemList_SetSelectedItem, ItemList_OpenContainer (for double-clicking a CONTAINER item specifically — opening it, not buying), but no generic double-click handler and no vendor-specific one either.
  • Other retail panels DO have an explicit, separately-named double-click handler when the mechanism exists — e.g. gmContractsUI::CheckForDoubleClick (0x00497A10), gmPageListUI::CheckForDoubleClick (0x00493140). No gmVendorUI::CheckForDoubleClick or VendorItemsUI::CheckForDoubleClick symbol exists in the 18,366-function named table.

Conclusion: retail's confirmed vendor-item interaction model is single-click-to-select (→ drives the global ACCWeenieObject::selectedID, B.3 below) plus an explicit Buy/Add button press. There is no evidence retail supports double-click-to-buy on the shop list. The user's expectation likely carries over from inventory-panel muscle memory (double-click = use/equip elsewhere in retail) — but the vendor "Items" list is not that panel. This is flagged as an open question for the contract, not resolved unilaterally: per the project's no-invented-mechanisms discipline, do not silently add a double-click-buy shortcut and call it retail-faithful. The retail-faithful, fully-evidenced fix for "double-click does nothing" is: (a) make single-click meaningfully select (today it only sets a private field with no visible effect — see B.3), and (b) make the Buy button actually work. If the user still wants a double-click shortcut after seeing single-click+Buy work, that is a deliberate, flagged acdream UX addition on top of retail, not a retail port — call it out explicitly in the commit/register the way AP-116 (Particle Range) or similar user-directed deviations are recorded.

B.3 — The quantity slider

The slider is not a vendor-panel widget. It is the TOOLBAR's shared stack-quantity control, reused by every "select a stackable item" interaction in the game (splitting an inventory stack, and — per this research — buying a partial stack from a vendor). Full trace:

  • gmToolbarUI::HandleSelectionChanged (pc:198635-198834, 0x004BF380) — the SAME function already partially cited in the existing code's VendorSplitSize doc comment — is the ONE seeding point. On every global selection change it:
    1. Hides this->m_pStackSizeEntryBox and this->m_pStackSizeSlider by default (pc:198660-198661).
    2. If the selected item's own _stackSize <= 1, leaves them hidden (single, non-splittable item — pc:198746-198766).
    3. Otherwise (splittable item), computes the SEED quantity with the exact vendor branch already ported into acdream's VendorUiController.VendorSplitSize (pc:198771-198788): if no vendor is open, or the item isn't owned by the open vendor, or the item's type does NOT intersect mask 0xDC41CB0, seed = the item's own stack size; else (vendor-owned AND type matches the exempt mask) seed = 1. Sets GenItemHolder::splitSize = seed, GenItemHolder::maxSplitSize = stackSize, writes the seed into the entry box text, sets the SLIDER's normalized position attribute (0x86) to splitSize / maxSplitSize, and makes BOTH controls visible (pc:198790-198820).
  • The player can then either drag the slider (element 0x100001A3, message 0xa, a delta-style update — gmToolbarUI::ListenToElementMessage, pc:198323-198346) or type into the entry box (element 0x100001A4, message 0x2f on focus-loss/enter, parsed via wcstoul and clamped — pc:198358-198400). Either path updates GenItemHolder::splitSize (clamped to [1, maxSplitSize]) and broadcasts CM_UI::SendNotice_StackSliderChanged(splitSize, maxSplitSize) (pc:198345, 0x0047A150) — a GLOBAL notice.
  • gmVendorUI::RecvNotice_StackSliderChanged (pc:203263-203278, 0x004C4500) is a REGISTERED LISTENER on that same global notice: if the vendor panel is visible and the globally-selected item is in the vendor's own shown list, it calls VendorItemsUI::UpdateItemsUI to re-render the name/cost text with the new quantity. The vendor panel never owns the slider — it only reacts to it.
  • ItemHolder::GetObjectSplitSize (pc:401465-401477, 0x00586F00) — the function BuySingleItem and every other purchase/split path reads for the actual transacted quantity — literally return GenItemHolder::splitSize; for the currently-selected object.

Conclusion: the "no slider" symptom is not a missing widget so much as a missing WIRE-UP. acdream ALREADY has a complete, byte-faithful port of this entire mechanism, unused by the vendor panel:

  • src/AcDream.Core/Items/StackSplitQuantityState.cs — ports GenItemHolder::splitSize/maxSplitSize exactly, including SetFromSliderRatio (the 0.25s scrollbar-to-integer conversion, citing UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0) and GetObjectSplitSize (citing ItemHolder::GetObjectSplitSize @ 0x00586F00 directly, line 51-61).
  • src/AcDream.App/UI/Layout/SelectedObjectController.cs — binds StackSizeEntryId = 0x100001A3, StackSizeSliderId = 0x100001A4 (lines 55-57) to real UiField/UiScrollbar elements on the TOOLBAR layout, wires visibility + seeding in ApplySelection (gmToolbarUI::HandleSelectionChanged port, lines 285-362), and round-trips slider drag / text entry back into StackSplitQuantityState (OnStackSliderChanged/CommitStackEntry, lines 408-431). Its own doc comment ALREADY calls out the exact gap: "stacks initialize to the full stack... Vendor-owned stack precedence is intentionally absent until the vendor panel owns an active vendor id." (lines 336-339).
  • src/AcDream.App/UI/Layout/ToolbarController.cs:39-41 independently confirms element ownership: "SelectedObjectController owns the health/mana meters and both stack controls."

The numeric neighborhood also confirms this is the SAME already-imported LayoutDesc: ToolbarController's own const ids bracket the slider exactly — AmmoIndicatorId = 0x10000194, then the slider pair 0x100001A3/0x100001A4, then UseButtonId = 0x1000019D, ExamineButtonId = 0x100001A5 (ToolbarController.cs:50-52, SelectedObjectController.cs:45-57) — all one contiguous authored block already resolved by the existing layout.FindElement calls at toolbar mount time. No new LayoutDesc import or dat discovery is needed — the elements are already found and bound; only the vendor-side trigger is missing.

B.4 — The global selection coupling (ACCWeenieObject::SetSelectedObject)

Confirmed call site with a vendor-owned guid: VendorSellUI::AddItemToSell (pc:203546-203567, 0x004C4A20) calls ACCWeenieObject::SetSelectedObject(arg2, 0) directly (pc:203558) when an item is dragged onto the sell tab — proving vendor-context items DO flow through the same global selection primitive as everything else, not a vendor-local one.

The fan-out mechanism: gmVendorUI::RecvNotice_SetSelectedItem (pc:199464-199470, 0x004C0280) is a registered listener on the global "selection changed" notice that forwards to THREE registered sub-listeners (hash buckets 0xc/0xd/0xe — almost certainly m_itemsUI, m_buyUI, m_sellUI, each of which has its own matching XxxUI::HandleSetSelectedItem symbol: VendorItemsUI::HandleSetSelectedItem @ 0x004C49B0, VendorBuyUI::HandleSetSelectedItem @ 0x004C0EA0, VendorSellUI::HandleSetSelectedItem @ 0x004C0F50, plus a shared VendorSubUI::HandleSetSelectedItem @ 0x004F5860 base). Reading VendorItemsUI::HandleSetSelectedItem (pc:203519-203524) — it is a pure UI-refresh reaction: UpdateItemsUI() + UpdateQuantityOverlay(), no state ownership. The vendor panel is a CONSUMER of the global selection, never its owner — exactly mirroring the toolbar/status-bar relationship in B.3.

Identifying a vendor-owned item in the selection: retail's own check (gmToolbarUI::HandleSelectionChanged, pc:198781) is eax_5->pwd._containerID != ClientUISystem::GetUISystem()->vendorID — the selected weenie's OWN _containerID field equals the currently-open vendor's guid. This requires the selected item to be a REAL client weenie object with a real _containerID/ContainerId — which is exactly why this finding is entangled with the acdream seam below (C.1): a vendor shop item that only exists as a VendorShopItem record (not a ClientObjectTable entry with a ContainerId) cannot be resolved this way.

B.5 — Constructing the outbound Buy message

Already fully traced in B.1/A.1: BuySingleItem builds a one-entry ItemProfile(amount=<split size>, guid=<selected id>) (pc:201749-201756), reads the vendor's VendorTradeCurrency for the trailing currency id, and calls CM_Vendor::Event_Buy(vendorId, &list, currencyId). No separate "build the message" step exists distinct from the button-click handler itself — retail does not stage-then-serialize; BuySingleItem does both the validation AND the send inline.


C. acdream seams

C.1 — The canonical selection owner, and its hard dependency

SelectionState (src/AcDream.Core/Selection/SelectionState.cs) is acdream's already-complete port of ACCWeenieObject::SetSelectedObject (its own doc comment says so verbatim, lines 34-39): dedup, previous-id tracking, and a Changed event fanned out to every listener with per-listener exception isolation — the exact shape RecvNotice_SetSelectedItem's fan-out has in B.4. It already has a SelectionChangeSource enum (System, World, Radar, Inventory, ExternalContainer, Paperdoll, Toolbar, Keyboard, Plugin) with a slot conspicuously reserved for exactly this kind of per-origin tagging — but no Vendor case exists yet.

Every OTHER retained panel already threads the SAME SelectionState singleton into its own bindings record: RadarRuntimeBindings.Selection (RetailUiRuntime.cs:43), MagicRuntimeBindings.Selection (:62), ToolbarRuntimeBindings.Selection (:104), InventoryRuntimeBindings.Selection (:131), ExternalContainerRuntimeBindings.Selection (:139). Real call sites: _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar) (RetailUiRuntime.cs:633), b.Selection.Select(guid, SelectionChangeSource.Toolbar) (:739). VendorRuntimeBindings (RetailUiRuntime.cs:171-173) is the only panel binding record missing a Selection field — it carries just VendorState State and ResolveIcon. This is a two-field record change plus one new enum case, not a new subsystem.

StackSplitQuantityState is likewise already a TOP-LEVEL singleton on RetailUiRuntimeBindings (RetailUiRuntime.cs:195, exposed as private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity; at line 208) — already reachable from MountVendor() (RetailUiRuntime.cs:1929) without any new plumbing at all, since it's not per-panel like Selection is.

The hard dependency (why this must come first, not last): SelectedObjectController's name/stack-size resolvers are wired, at the composition root, DIRECTLY against ClientObjectTable: ResolveName: guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName() and StackSize: guid => (uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0) (src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:646, 649-650). Vendor shop items are not registered in ClientObjectTable today — confirmed by AP-161 finding #2 (docs/architecture/retail-divergence-register.md) and by reading VendorState.Apply (VendorState.cs:144-182), which only stores items in its own IReadOnlyList<VendorShopItem>, and GameEventWiring's ApproachVendor handler (per AP-161's own text), which "only calls vendor?.Apply(...), never touches items/ClientObjectTable."

Consequence: if VendorUiController's row click is wired to SelectionState.Select(item.ItemGuid, SelectionChangeSource.Vendor) before shop items are registered in ClientObjectTable, the status bar will show a BLANK name and a stack size of 0 for a selected shop item — a regression dressed as a fix, not a working feature. The Slice 5 doc's already-made recommendation (§A.2 point 4: register each ApproachVendor item into ClientObjectTable the way retail materializes a real CWeenieObject per shop item, pc:203720-203748) is therefore not optional polish for Slice 6 — it is the load-bearing prerequisite for symptoms 3 and 4 both, and it simultaneously unlocks AP-161's finding #2 (shop-item examine, currently a hard-STOP because AppraisalUiController.Apply requires a live ClientObjectTable entry — src/AcDream.App/UI/Layout/AppraisalUiController.cs:418-420).

A concrete registration mechanism already exists to reuse: ClientObjectTable.Ingest(WeenieData) is how ordinary CreateObject entries get registered (ObjectTableWiring.ApplyEntitySpawn, src/AcDream.Core.Net/ObjectTableWiring.cs:105-130, table.Ingest(data) at line 124). Since a VendorShopItem's wire source is literally the same PublicWeenieDesc body a CreateObject carries (Slice 5 doc §A.2 point 3), building an equivalent WeenieData per shop item (guid, ContainerId = vendorGuid, name/type/icon/stack fields already captured on VendorShopItem, VendorState.cs:38-78) and calling the same Ingest path is the natural, minimal-new-code route — not a new registration mechanism.

Once shop items ARE in ClientObjectTable and VendorRuntimeBindings carries Selection, retail's vendor-owned split-exempt-mask branch (B.3) needs exactly one more piece: SelectedObjectController.ApplySelection currently seeds StackSplitQuantityState.Reset(stackSize) unconditionally for any stackSize > 1u (SelectedObjectController.cs:340-345) — it has no notion of "is this a vendor-owned item, and does its type match the split-exempt mask." The mask constant already exists (ported once, for display-only purposes) as VendorUiController.SplitExemptMask = 0xDC41CB0 (VendorUiController.cs:163, pc:198784, gmToolbarUI::HandleSelectionChanged). The contract should either (a) move this mask + its "is this guid owned by the currently-open vendor" check into SelectedObjectController/a small shared helper both it and VendorUiController call, or (b) inject "vendor id + split-exempt predicate" as a delegate into SelectedObjectController.Bind the same way health/mana/name are already injected — a genuine, bounded design decision for the contract, not a research finding to pre-decide.

C.2 — The outbound GameAction builder pattern

src/AcDream.Core.Net/Messages/InteractRequests.cs is the established precedent: one public static class per family, one public const uint opcode, one public static byte[] BuildXxx(uint gameActionSequence, ...) per message shape writing the fixed 0xF7B1 envelope + sequence + opcode + fields with BinaryPrimitives.WriteXxxLittleEndian (see BuildUse, BuildUseWithTarget, BuildTeleToLifestone, BuildPickUp — lines 38-108). None of the existing builders in this file handle a variable-length list payload yet (Buy needs itemCount + N variable items), but the pattern extends trivially (compute 16 + 8*itemCount bytes, write the count, loop writing amount/guid pairs, then the trailing currency u32).

The send-site pattern lives on WorldSession (src/AcDream.Core.Net/WorldSession.cs): every SendXxx method is NextGameActionSequence()SomeRequests.BuildXxx(seq, ...)SendGameAction(body) (e.g. SendTeleportToLifestone, lines 2078-2082; SendTalk, lines 2040-2046). A SendBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId) following this exact three-line shape is the natural addition, paired with a new VendorRequests.BuildBuy (or extending InteractRequests) static builder.

Existing sibling bindings show the exact shape a SendBuy delegate should have when threaded into VendorRuntimeBindings: InventoryRuntimeBindings.SendUse is Action<uint>?, SendPutItemInContainer is Action<uint,uint,int>?, SendStackableSplitToContainer is Action<uint,uint,uint,uint>? (RetailUiRuntime.cs:126-129) — all nullable nullary-return Action<...> delegates constructed at the composition root as guid => late.Session.CurrentSession?.SendXxx(...) (InteractionRetainedUiComposition.cs:668-673 for the Inventory block).

C.3 — Existing split/quantity UI

Already fully covered in B.3/C.1: the quantity UI is not missing, it is unwired for vendor. SelectedObjectController + StackSplitQuantityState are the complete, already-shipped port living on the retail toolbar LayoutDesc (0x21000016) that ToolbarController already imports. No new LayoutDesc discovery, no new dat-extraction pass, and no new widget class are needed for the slider itself.

C.4 — Money display and the reconciliation loop

VendorUiController.BuildCostText already reads the player's holdings live from ClientObjectTable: _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) (VendorUiController.cs:691). PropertyInt.CoinValue updates are ALREADY generic, wired-once machinery: ObjectTableWiring.Wire's PrivateUpdatePropertyInt (0x02CD) handler applies ANY int property to the player's ClientObjectTable entry with no per-property special-casing (src/AcDream.Core.Net/ObjectTableWiring.cs:49-62) — this is the SAME path UpdateCoinValue's server-side GameMessagePrivateUpdatePropertyInt(PropertyInt.CoinValue, ...) (A.2 above) lands on.

The full reconciliation loop after a successful buy — coin deduction, new item appearing in inventory, encumbrance update — is already fully covered by existing generic machinery with zero vendor-specific code needed: GameMessageCreateObject/GameEventItemServerSaysContainId for the new item (standard inventory-placement plumbing, J4.2), and PrivateUpdatePropertyInt for CoinValue/EncumbranceVal (above). The vendor panel's own cost/price text will refresh automatically on the next ApproachVendor (A.2 step 4) → VendorState.ApplyVendorUiController.OnVendorChanged's Refreshed case → RebuildCategories()RebuildItemList() → (if the prior selection survives the rebuild) SelectItem(), which re-reads CoinValue live — matching retail's own OpenVendor-refresh-driven redraw model exactly (no polling, no separate "did money change" event needed).

Net effect for the contract: Slice 6's true remaining implementation surface is narrow. The generic reconciliation plumbing (money, inventory placement, panel refresh-on-reopen) is DONE. What's actually missing is: (1) shop items in ClientObjectTable, (2) SelectionChangeSource.Vendor + wiring the row click and Buy button, (3) the outbound Buy builder + a SendBuy dispatch through the existing UseDone gate, (4) threading SelectionState/a vendor-owned split predicate so the slider shows and seeds correctly.


D. Scope recommendation

Minimal retail-faithful ordering, in dependency order (each step is concretely unblocked by the one before it; skipping ahead reproduces the "blank status bar" regression risk called out in C.1):

  1. Materialize ApproachVendor shop items into ClientObjectTable (guid, ContainerId = vendorGuid, the fields VendorShopItem already captures) via the existing Ingest(WeenieData) path (ObjectTableWiring.ApplyEntitySpawn's pattern). This is the prerequisite for everything else and simultaneously retires half of AP-161's finding #2 (shop-item examine becomes reachable once AppraisalUiController.Apply's ClientObjectTable lookup succeeds).
  2. Add SelectionChangeSource.Vendor; add SelectionState Selection (and a vendor-owned split-exempt predicate, C.1's open design question) to VendorRuntimeBindings; change VendorUiController's row click (cell.Clicked, line 567) to call SelectionState.Select(item.ItemGuid, SelectionChangeSource.Vendor) instead of the private SelectItem/_selectedItemGuid path. This single change, given step 1 is done, fixes symptom 4 (status bar) AND symptom 3 (slider — because SelectedObjectController.ApplySelection already shows/seeds the toolbar slider for any stack >1u once the guid resolves through ClientObjectTable) as a side effect of routing through the REAL owner instead of reimplementing display logic locally.
  3. The outbound Buy wire message + dispatch through the existing UseDone gate (C.2/A.4): a VendorRequests.BuildBuy builder, WorldSession.SendBuy, a SendBuy delegate threaded into VendorRuntimeBindings, and a call from the Buy button (below) routed through RuntimeInteractionTransactionState's existing single-flight reservation the same way ordinary Use is.
  4. Wire _buyButton.OnClick to read SelectionState.SelectedObjectId + StackSplitQuantityState.GetObjectSplitSize(...) and call the new SendBuy — porting BuySingleItem's exact shape (B.1): client-side affordability pre-check optional (server already validates and sends a clear-enough failure signal per A.2 — a nice-to-have, not required for correctness), immediate single-item purchase, no staging list required.

Explicitly deferred, not required to fix the four reported symptoms:

  • VendorBuyUI/VendorSellUI staging (the "Buying"/"Selling" tabs' Add/Buy Item/Buy All/Clear buttons) — B.1 proves the Buy button works completely independently of staging. Leave these tabs exactly as AP-161 already documents them (present, switch pages, inert).
  • Double-click-to-buy — B.2 found no retail precedent; do not add it silently. Surface as an explicit open question (below) rather than guessing at a UX addition.
  • Sell (A.3) — full mirror wire shape noted for when the sell UI is built; not part of this pass.
  • VendorProfile::InqAcceptability (sell-eligibility highlighting) — unchanged from the Slice 5 fence; still meaningless without a sell UI.

Open questions for the contract

  1. Client-side pre-checks (affordability, capacity) before sending Buy — retail does them (B.1); ACE also validates server-side and sends a distinguishable-enough failure signal (A.2). Recommendation: skip the client-side pre-check for this pass (it's pure latency/UX polish, not correctness — the server is authoritative either way) and file it as a fast follow-up if the user notices the round-trip lag on a refused purchase.
  2. Double-click — no retail mechanism found (B.2). Ask the user directly whether they want a deliberate acdream-only double-click shortcut once single-click-select + Buy-button-works is verified live, rather than assuming yes and inventing behavior.
  3. Where does the vendor-owned split-exempt-mask predicate live — C.1's design question: fold into SelectedObjectController directly (it already owns the seeding logic, would need a Func<uint,bool> isVendorOwnedExempt delegate), or keep it a small shared static helper both SelectedObjectController and VendorUiController call. Either is defensible; the contract should pick one rather than duplicating the 0xDC41CB0 mask logic a second time (it already exists once, as display-only logic, in VendorUiController.VendorSplitSize — that copy should be deleted once the real seeding path exists, not left as a second source of truth).
  4. Trailing AlternateCurrencyId field on the outbound Buy message — A.1 resolved the ACE-vs-retail tension in favor of porting it (retail sends it; ACE currently ignores it but doesn't break if present). Confirm no objection before implementation, since it's the one place this document recommends porting a field the CURRENT ACE server build demonstrably doesn't need.
  5. WeenieData/Ingest construction for shop items — the exact field mapping from VendorShopItem (VendorState.cs:38-78) to whatever WeenieData shape ClientObjectTable.Ingest expects wasn't traced to the field level in this pass (time-boxed); a focused read of ObjectTableWiring.ToWeenieData (referenced at ObjectTableWiring.cs:115) against VendorShopItem's field list is a short follow-up before implementation, not a re-open of this document's conclusions.