docs(plan): Slice 6 buy-arc contract — selection coupling root cause, 0x005F wire, retail's no-double-click truth
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e602f84be2
commit
c884a938e0
2 changed files with 822 additions and 0 deletions
765
docs/research/2026-08-08-slice6-vendor-transactions-research.md
Normal file
765
docs/research/2026-08-08-slice6-vendor-transactions-research.md
Normal file
|
|
@ -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<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue