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

765 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (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 = 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<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 = 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 = <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.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<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.