diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index d5fe8928..329fe31a 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -631,3 +631,60 @@ commit). Slice gate (user, connected, ~3 min): approach a Holtburg vendor, use them, the authored panel opens on the browse tab with retail-correct items/icons/prices; category tabs filter; walking out of range closes the panel by itself; nothing is purchasable anywhere. + +## Slice 6 — vendor transactions, buy arc (contract authored 2026-08-08; user-pulled forward) + +**Research:** `docs/research/2026-08-08-slice6-vendor-transactions-research.md`. +User direction: "I cant buy anything... Fix that first." Root cause of all +four reported symptoms: `VendorUiController` never touches the shared +`SelectionState`/`StackSplitQuantityState` owners every other panel uses. + +### Decisions + +1. **Shop items materialize into `ClientObjectTable`** while the session is + open (retail creates real CWeenieObjects from the vendor list — Slice 5 + research §A.2) and are REMOVED on session close/replace/reset. The + implementer verifies retail's removal site (gmVendorUI::CloseVendor + family) and mirrors its lifecycle. This dissolves F7c's blocker: + `ExamineItemRequested` gets wired in this slice. +2. **Vendor selection is the GLOBAL selection**: a new vendor change source + on the canonical `SelectionState`; row-click selects through it; the + status bar and the existing byte-faithful `StackSplitQuantityState` + slider follow automatically (the split-size mask helper from 5.4's F2 + feeds the vendor-owned seeding exactly as gmToolbarUI does at + pc:198635-198790). +3. **Buy = retail's Buy button**: immediate single-item purchase + (gmVendorUI::BuySingleItem, pc:201661) of the selected item with the + slider-chosen quantity for stacks. Outbound `0x005F`: vendorGuid, + count, per-item (i32 amount, u32 guid), TRAILING u32 + alternateCurrencyId — the real client sends it (CM_Vendor::Event_Buy, + pc:689288) even though ACE's reader ignores it; we port the real + client. The request rides the EXISTING J5.2 one-request-at-a-time + reservation and completes on `UseDone` (0x01C7) — already the wired + completion signal; no second gate. +4. **Reconciliation is the existing inbound machinery**: money property + updates, inventory CreateObject, and the ApproachVendor refresh + (VendorState.Refreshed) all flow through landed handlers — the slice + VERIFIES the loop end-to-end rather than adding an owner. +5. **No double-click-to-buy**: retail has no such mechanism (confirmed + against the full named table). We match retail. If the user wants it + as a deliberate modernization it needs their explicit call + an AP row. +6. **Deferred, still AP-161**: the Add button / Buying-tab staging list + and everything Sell (0x0060 — researched, next arc). + +### Ordered work (one implementer, bisectable commits) + +- **6.1** shop-item materialization + removal lifecycle + examine wiring. +- **6.2** the selection coupling (source, row-click, split seeding) — + status bar + slider light up. +- **6.3** the 0x005F builder (golden-byte tests incl. the trailing dword), + Buy-button wiring, gate/UseDone completion, and the verified + reconciliation round-trip. Register: AP-161 narrowed in the landing. + +### Gate (user, connected) + +Select a stacked item → it shows in the status bar with the slider; pick +a quantity; Buy → coins drop by the displayed price, the stack lands in +the pack, the shop refreshes; a single-item buy works; insufficient funds +fails cleanly; the session still closes on walk-away/portal with the +materialized items removed. diff --git a/docs/research/2026-08-08-slice6-vendor-transactions-research.md b/docs/research/2026-08-08-slice6-vendor-transactions-research.md new file mode 100644 index 00000000..47052444 --- /dev/null +++ b/docs/research/2026-08-08-slice6-vendor-transactions-research.md @@ -0,0 +1,765 @@ +# 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 nothing** — `VendorUiController` 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 bar** — `VendorUiController` + 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 (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.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` (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 }` — + `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 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 = 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: + +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 gate** — + `RuntimeInteractionTransactionState` (`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 = `, `var_98 = `, `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=, guid=)` +(`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`, 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?`, `SendPutItemInContainer` is `Action?`, +`SendStackableSplitToContainer` is `Action?` +(`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): + +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 + 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.