44 KiB
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:
- Buy button does nothing —
VendorUiControllerbuilds_buyButtonbut never assigns.OnClick(VendorUiController.cs:211,368-369; confirmed by reading the whole file — no_buyButton.OnClick =assignment exists anywhere). - 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 PRIVATESelectItem. - No slider for stackable items — the real slider machinery
(
SelectedObjectController,StackSplitQuantityState) already exists and is fully built, butVendorUiControllernever touches it; it computes its own localVendorSplitSize(item)for display only (VendorUiController.cs:611-651). - Selection doesn't show in the status bar —
VendorUiControllerkeeps a PRIVATE_selectedItemGuidfield (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 (2–4) 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 = 0x005F —
references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:51.
Handler: GameActionBuyItems.Handle →
references/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.HandlereadsvendorGuid(u32),numItems(u32), then per itemamount(i32) thenobjectID(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_BuywritesObjectId(u32), aPackableList<ItemProfile>(count + items), thenAlternateCurrencyId(u32) —references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Vendor_Buy.generated.cs:33-48. ItsItemProfiletype (Types/ItemProfile.generated.cs) is the SAME type used for the S2CApproachVendoritem list, but for an outbound Buy the high byte ofPackedAmountis always 0 (small positive quantities never set bits 24-31), soPwdTyperesolves to 0 and the switch falls through without reading aPublicWeenieDescbody — 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 withvendor_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), signatureEvent_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 opcode0x5f(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 contrastCM_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 = 0x0060 — GameActionType.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.HandleActionBuyItem →
Vendor.BuyItems_ValidateTransaction (on success) →
Player.FinalizeBuyTransaction (references/ACE/Source/ACE.Server/WorldObjects/Player_Commerce.cs:22-116,
Vendor.cs:431-571), IN ORDER:
SpendCurrency→ for a pyreal vendor, destroys pyreal stacks viaTryConsumeFromInventoryWithNetworkingwhich sendsGameMessageInventoryRemoveObject/GameMessageSetStackSize(whichever applies) thenUpdateCoinValue(), which sendsGameMessagePrivateUpdatePropertyInt(PropertyInt.CoinValue, ...)only if the value actually changed (Player_Commerce.cs:319-333,Player_Inventory.cs:141-146).- 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). GameMessageSound(PickUpItem).vendor.ApproachVendor(this, VendorType.Buy, altCurrencySpent)— a fresh, full-replaceApproachVendor(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 aVendorStateTransitionKind.Refreshedin acdream'sVendorState.Apply,src/AcDream.Core/Items/VendorState.cs:144-182— no new state-machine case needed, only a trigger).- 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 enqueuesGameEventInventoryServerSaveFailedfirst).
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:
- ACE: every code path through
HandleActionBuyItemends in exactly oneSendUseDoneEvent()call, success or failure (A.2 above) — it is structurally the terminal event of the request, the same as ordinaryUse. - holtburger models Buy/Sell as
BusyOperationKind::Buy/::Sell, armed via the SAMEarm_busy_operationsingle-flight gate ordinaryUse/UseWithTargetuse (references/holtburger/crates/holtburger-core/src/client/commands.rs:526-545,430-438), and its own tests prove completion fires onGameEvent::UseDone(references/holtburger/crates/holtburger-core/src/client/mod.rs:646-680, and thecommands.rs:2131-2195integration tests) — never on a money orApproachVendorevent. - acdream already has the exact matching gate —
RuntimeInteractionTransactionState(src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs) ownsBeginUseRequestReservation()/TryDispatchUse(...)(the single-flight "one request outstanding" gate) andCompleteUse(uint error)(line 215-222), which is ALREADY wired to the inboundUseDone(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. ReadingBuySingleItemitself (pc:201661,0x004C2820) in full:- Reads the selected item's own
_stackSize; if<=1uses quantity 1, else callsItemHolder::GetObjectSplitSize(i.e. the CURRENT slider value) as the purchase quantity (pc:201674-201681). - Computes the price via
VendorProfile::VendorSellPriceand does a client-side affordability pre-check againstthis->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 viaECM_UI::SendNotice_DisplayStringInfoand 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
ItemProfilelist (var_9c = <split size>,var_98 = <selected guid>,pc:201750-201756), callsCM_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'sIsBusygate (pc:201764-201765).
- Reads the selected item's own
0x100000C3(Add to List button, "Items" tab) → looks up the selected weenie, computes its split size the same way, then callsVendorItemsUI::AddToBuyList(this->m_itemsUI, item, quantity)(pc:203971-203985). This stages the item intothis->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 sameBuySingleItem), 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 callsgmVendorUI::SendShopEvent(this, shopVendorID, &this->m_buyList, currencyId, SE_BUY)(pc:204075) — sends the ENTIRE staged list in oneEvent_Buycall, 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), and0x1c(routes toHandleMousePressesonly whenm_itemsUI != 0) — there is no distinct "double-click" message id handled at the panel level.- The base list class
UIElement_ItemList(every method enumerated viadocs/research/named-retail/symbols.json, ~50 symbols) hasHandleSingleSelection,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). NogmVendorUI::CheckForDoubleClickorVendorItemsUI::CheckForDoubleClicksymbol 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'sVendorSplitSizedoc comment — is the ONE seeding point. On every global selection change it:- Hides
this->m_pStackSizeEntryBoxandthis->m_pStackSizeSliderby default (pc:198660-198661). - If the selected item's own
_stackSize <= 1, leaves them hidden (single, non-splittable item —pc:198746-198766). - 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 mask0xDC41CB0, seed = the item's own stack size; else (vendor-owned AND type matches the exempt mask) seed = 1. SetsGenItemHolder::splitSize = seed,GenItemHolder::maxSplitSize = stackSize, writes the seed into the entry box text, sets the SLIDER's normalized position attribute (0x86) tosplitSize / maxSplitSize, and makes BOTH controls visible (pc:198790-198820).
- Hides
- The player can then either drag the slider (element
0x100001A3, message0xa, a delta-style update —gmToolbarUI::ListenToElementMessage,pc:198323-198346) or type into the entry box (element0x100001A4, message0x2fon focus-loss/enter, parsed viawcstouland clamped —pc:198358-198400). Either path updatesGenItemHolder::splitSize(clamped to[1, maxSplitSize]) and broadcastsCM_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 callsVendorItemsUI::UpdateItemsUIto 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 functionBuySingleItemand every other purchase/split path reads for the actual transacted quantity — literallyreturn 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— portsGenItemHolder::splitSize/maxSplitSizeexactly, includingSetFromSliderRatio(the0.25sscrollbar-to-integer conversion, citingUIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0) andGetObjectSplitSize(citingItemHolder::GetObjectSplitSize @ 0x00586F00directly, line 51-61).src/AcDream.App/UI/Layout/SelectedObjectController.cs— bindsStackSizeEntryId = 0x100001A3,StackSizeSliderId = 0x100001A4(lines 55-57) to realUiField/UiScrollbarelements on the TOOLBAR layout, wires visibility + seeding inApplySelection(gmToolbarUI::HandleSelectionChangedport, lines 285-362), and round-trips slider drag / text entry back intoStackSplitQuantityState(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-41independently 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.Apply →
VendorUiController.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):
- Materialize
ApproachVendorshop items intoClientObjectTable(guid,ContainerId = vendorGuid, the fieldsVendorShopItemalready captures) via the existingIngest(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 onceAppraisalUiController.Apply'sClientObjectTablelookup succeeds). - Add
SelectionChangeSource.Vendor; addSelectionState Selection(and a vendor-owned split-exempt predicate, C.1's open design question) toVendorRuntimeBindings; changeVendorUiController's row click (cell.Clicked, line 567) to callSelectionState.Select(item.ItemGuid, SelectionChangeSource.Vendor)instead of the privateSelectItem/_selectedItemGuidpath. This single change, given step 1 is done, fixes symptom 4 (status bar) AND symptom 3 (slider — becauseSelectedObjectController.ApplySelectionalready shows/seeds the toolbar slider for any stack>1uonce the guid resolves throughClientObjectTable) as a side effect of routing through the REAL owner instead of reimplementing display logic locally. - The outbound Buy wire message + dispatch through the existing
UseDonegate (C.2/A.4): aVendorRequests.BuildBuybuilder,WorldSession.SendBuy, aSendBuydelegate threaded intoVendorRuntimeBindings, and a call from the Buy button (below) routed throughRuntimeInteractionTransactionState's existing single-flight reservation the same way ordinaryUseis. - Wire
_buyButton.OnClickto readSelectionState.SelectedObjectId+StackSplitQuantityState.GetObjectSplitSize(...)and call the newSendBuy— portingBuySingleItem'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/VendorSellUIstaging (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
- 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.
- 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.
- Where does the vendor-owned split-exempt-mask predicate live — C.1's
design question: fold into
SelectedObjectControllerdirectly (it already owns the seeding logic, would need aFunc<uint,bool> isVendorOwnedExemptdelegate), or keep it a small shared static helper bothSelectedObjectControllerandVendorUiControllercall. Either is defensible; the contract should pick one rather than duplicating the0xDC41CB0mask logic a second time (it already exists once, as display-only logic, inVendorUiController.VendorSplitSize— that copy should be deleted once the real seeding path exists, not left as a second source of truth). - Trailing
AlternateCurrencyIdfield 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. WeenieData/Ingestconstruction for shop items — the exact field mapping fromVendorShopItem(VendorState.cs:38-78) to whateverWeenieDatashapeClientObjectTable.Ingestexpects wasn't traced to the field level in this pass (time-boxed); a focused read ofObjectTableWiring.ToWeenieData(referenced atObjectTableWiring.cs:115) againstVendorShopItem's field list is a short follow-up before implementation, not a re-open of this document's conclusions.