diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 329fe31a..abdb8a5e 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -688,3 +688,48 @@ a quantity; Buy → coins drop by the displayed price, the stack lands in the pack, the shop refreshes; a single-item buy works; insufficient funds fails cleanly; the session still closes on walk-away/portal with the materialized items removed. + +## Slice 6b/6c — vendor completion (contract authored 2026-08-08) + +**Research:** `docs/research/2026-08-08-slice6b-vendor-completion-research.md`. +Closes the user's seven-finding gate batch (dropdown polish landed at +`33b45ee5`). Ordered chunks, one implementer: + +1. **Move-to-use (Q2):** wire the existing client-predicted approach + primitive (`PlayerInteractionMovementSink.BeginApproach`, + `MovementType.MoveToObject` — today Pickup-only) onto `RequestUse`, so + using a vendor (or anything) beyond range walks the player in first, + retail's shape. No new movement machinery. +2. **Buy staging (Q3):** `AddToBuyList` semantics per the research + trace — Add stages the selection + slider quantity into the Buying + tab's list (rendered count/price), Buy on that tab sends ONE batched + 0x005F with every staged entry, the two removal shapes + Clear, and + retail's X-close confirm dialog when staging is non-empty (the current + unconditional hide stays correct only for empty staging). +3. **Selling (Q4):** the Selling tab's list is THE drop target — accept + pack-item drops via the `ExternalContainerController` drag-handler + pattern, filter through `InqAcceptability` (all four rejection reasons + with retail's exact strings), staged sell list, batched 0x0060, and + the existing reconciliation machinery. +4. **Status bar (Q5) — user evidence is the axiom:** the code-reading + says our chain already matches retail, but the user's live session + says the stack count/value/split-bar presentation is absent for a + vendor selection. Reproduce in a UI-level test FIRST (drive the real + SelectedObjectController mount with a vendor selection); fix what the + reproduction reveals; if it genuinely cannot reproduce, STOP and + report with the test as evidence for a live-probe session. +5. **Pack order (Q1):** code-verified correct (wire placement position → + front insert). NO change; the gate re-checks it live and #352 gets + filed only if it reproduces. + +Register: same-commit rows for any deviation; AP-161 narrows again as +staging/sell land (its remaining scope should shrink to nothing or to +precisely what stays absent). + +**Gate (user):** click a vendor from afar → walk-in + open; stage two +different items with quantities → Buy All → one transaction, coins/items +correct; drag a sellable item onto the Selling tab → stages → Sell → +coins up, item gone; an InqAcceptability-rejected item shows retail's +refusal; X with a staged list → confirm dialog; stacked selection shows +count/value/split-bar in the toolbar; bought items land at the front of +the pack. diff --git a/docs/research/2026-08-08-slice6b-vendor-completion-research.md b/docs/research/2026-08-08-slice6b-vendor-completion-research.md new file mode 100644 index 00000000..2d563c97 --- /dev/null +++ b/docs/research/2026-08-08-slice6b-vendor-completion-research.md @@ -0,0 +1,893 @@ +# Slice 6b/6c vendor-completion research — staging, selling, and three live-gate gaps + +**Date:** 2026-08-08 +**Trigger:** the user's live connected gate on the Slice 6 buy arc (`97cf8738`, +`3c9fc57a`, `5224e438`) surfaced three residual mechanism gaps (bought items +land last, vendor use-range feels too tight, the stacked-item status bar is +missing pieces) plus the two fenced-out features (Buying-tab staging, +Selling-tab drag-to-sell). This document answers all three gaps and both +staging mechanisms with retail citations, so the implementer can pick up +Slice 6b (staging) and 6c (selling) without re-deriving the decomp. + +**Verified starting point:** repo HEAD at research time was `5224e438` +("fix(vendor): gate-findings pass — the X button HIDES like retail, clicks +return, the dropdown scrolls, pyreal suffix, staged-tab slots"). Read-only +research; no code changed. `src/AcDream.App/UI/Layout/UiMenu.cs` and +`VendorUiController.cs` are owned by a parallel implementer in this session — +both were read in full for this document but are cited, not edited. + +**Mandatory prior reading done first:** +`docs/research/2026-08-08-slice5-vendor-browse-research.md` (browse lifecycle, +the D0 layout tree with all vendor-panel element ids, the Items/Buying/Selling +tab discovery) and `docs/research/2026-08-08-slice6-vendor-transactions-research.md` +(§A has the byte-verified 0x005F Buy payload; §B.1 has `BuySingleItem` and +the Add-to-List staging pointer this document expands). Neither document is +re-derived here — findings are cited forward. + +--- + +## Q1 — bought items land LAST; retail puts them FIRST + +**Short answer: the insert-position rule is 100% server-sourced, ACE defaults +new inventory items to position 0 (front), and acdream already has a +byte-faithful port of the exact retail positional-insert algorithm, already +wired to the same wire field. On paper this already produces "bought items +land first." If the live symptom persists, the mechanism itself is not the +likely suspect — see the narrow open question at the end of this section.** + +### Retail's insert-position mechanism + +`ACCWeenieObject::ServerSaysContainID` (`pc:405992`, `0x0058be40`) is the +client-side handler for the `ContainID` UI-queue event (case `0x22` in +`UIQueueManager::ProcessNetBlobData`, `pc:359268-359293`): + +``` +void ACCWeenieObject::ServerSaysContainID(this, itemId, position, containerTypeFlag) +{ + IDList* list = (containerTypeFlag == 0) ? &objInventory->_itemsList + : &objInventory->_containersList; + return IDList::AddAtNum(list, itemId, position, /*allowAppend*/ 1); +} +``` + +`IDList::AddAtNum` (`pc:443381`, `0x005add20`) is a genuine positional +doubly-linked-list insert: it walks to the node currently at index +`position` and splices the new node in BEFORE it (or appends if +`position == numIDs`). **The position is not computed locally — it is the +literal `arg3` the caller passed in, sourced from the wire.** + +The caller (`UIQueueManager::ProcessNetBlobData` case `0x22`, `pc:359268-359291`) +reads four fields off the payload in order — item guid, container guid, a +third field (`var_1b8`, passed as the position), and a fourth field +(`var_1b0`, passed as the container-type flag) — then calls +`ACCWeenieObject::ServerSaysContainID(containerObj, itemId, var_1b8, var_1b0)`. + +### ACE's wire writer confirms the field mapping and the default value + +`GameEventItemServerSaysContainId` (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventItemServerSaysContainId.cs:7-14`): + +```csharp +Writer.WriteGuid(itemToBeContained.Guid); +Writer.WriteGuid(container.Guid); +Writer.Write(itemToBeContained.PlacementPosition ?? 0); // ← the position field +Writer.Write((uint)itemToBeContained.ContainerType); // ← the container-type flag +``` + +This is byte-identical to the four fields `ServerSaysContainID` reads +(guid, container, position, type flag). **`PlacementPosition` on the wire is +literally retail's insert index.** + +`Container.TryAddToInventory(WorldObject, out Container, int placementPosition = 0, ...)` +(`references/ACE/Source/ACE.Server/WorldObjects/Container.cs:499`) defaults +`placementPosition` to **0** and, when placing an item, shifts every +existing same-category item's `PlacementPosition` up by one +(`Container.cs:567-570`): + +```csharp +worldObject.PlacementPosition = placementPosition; +containerItems.Where(i => !i.UseBackpackSlot && i.PlacementPosition >= placementPosition) + .ToList().ForEach(i => i.PlacementPosition++); +``` + +`Player.TryCreateInInventoryWithNetworking(WorldObject, out Container)` +(`Player_Inventory.cs:90-115`) — the method `FinalizeBuyTransaction` calls +for every purchased item (`Player_Commerce.cs:73-95`) — calls the **2-arg** +`TryAddToInventory(item, out container)` overload, which resolves to the +3-arg overload's default `placementPosition = 0`. There is only one matching +overload (`Container.cs:390` and `Container.cs:499`), so this is +unambiguous: **every ordinary item creation in ACE — buy, pickup, gem +identification, crafting output — places the new item at position 0, +pushing everything else back one slot.** + +### acdream already ports this exact mechanism + +`ClientObjectTable.InsertContainerMember` (`src/AcDream.Core/Items/ClientObjectTable.cs:1108-1142`) +is a direct, already-cited port of `IDList::AddAtNum`'s category-aware +positional insert: + +```csharp +/// Port of retail ACCWeenieObject::AddContent @ 0x0058CCE0: items and +/// child containers have separate ordered IDLists and IDList::AddAtNum +/// clamps the requested index to the list length. +private void InsertContainerMember(ClientObject item, int requestedSlot) +``` + +It is reached from `ApplyServerMove` → `ApplyPlacement(..., retailContainerInsert: true)` +(`ClientObjectTable.cs:380-437`), which is called directly from the +`InventoryPutObjInContainer` (0x0022) wire handler +(`src/AcDream.Core.Net/GameEventWiring.cs:356-367`): + +```csharp +registrar.Register(GameEventType.InventoryPutObjInContainer, e => +{ + var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); + if (p is null) return; + items.ApplyConfirmedServerMove( + p.Value.ItemGuid, p.Value.ContainerGuid, + newWielderId: 0u, + newSlot: (int)p.Value.Placement, // ← the SAME wire field ACE writes + containerTypeHint: p.Value.ContainerType); +}); +``` + +`GameEvents.ParsePutObjInContainer` (`src/AcDream.Core.Net/Messages/GameEvents.cs:390-404`) +already documents the field layout with the exact ACE citation. And +`InventoryController.Populate()` (`src/AcDream.App/UI/Layout/InventoryController.cs:378-395`) +reads the pack's display order straight off +`ClientObjectTable.GetContents(open)` (`ClientObjectTable.cs:1269-1271`), +which returns the SAME `_containerIndex` list `InsertContainerMember` +maintains — there is no separate/secondary sort in the panel. +`InventoryController` also subscribes to `ObjectMoved`/`ContainerContentsReplaced` +(`InventoryController.cs:194-197`) and repaints on both, so a position +correction that lands after the item's own `CreateObject` is not stale in +the render. + +**Net: every link in the chain — wire field → ACE default → acdream parser → +acdream positional insert → acdream panel read — already matches retail +insert-at-position-0.** This is not a one-line fix; there does not appear to +be a missing piece. + +### Open question — if the symptom is still observed live + +Everything above is verified from source, not from a live trace (this was a +read-only research pass). The one path this document did NOT rule out: +**stack-merge.** If ACE decides a purchased stackable item can merge into an +*existing* pack stack of the same WCID rather than creating a new item +(some games do this before falling back to `TryCreateInInventoryWithNetworking`), +the result would be a `GameMessageSetStackSize` on the existing item with NO +`ContainId`/position change at all — the item would stay wherever it already +was, appearing to "not move," which a user could report as "landed at the +end" if the existing stack happened to be at the end of the pack. This +document did not trace `ItemProfileToWorldObjects`/`Vendor.BuyItems_ValidateTransaction` +far enough to rule this in or out for every item category. **Recommendation:** +before writing any code, do a single live buy of a fresh (never-before-owned) +item and confirm placement with `ACDREAM_DUMP_CELLS`-style instrumentation or +a breakpoint on `ApplyConfirmedServerMove`, rather than re-deriving the +already-correct positional-insert logic above. + +--- + +## Q2 — the vendor opens only at very close range + +**Short answer: neither retail's client nor acdream's client gates the Use +SEND on distance — both send it unconditionally. The actual "walk to the +vendor" mechanic is entirely SERVER-driven in retail (ACE's `CreateMoveToChain`), +delivered back to the mover's own client as an ordinary broadcast motion +command, not as local client prediction. acdream has a complete, already-built +client-predicted move-to-target mechanism (`PlayerInteractionMovementSink.BeginApproach`), +but today it is wired ONLY to pickup, never to Use/Activate.** + +### (a) Neither client gates Use by distance + +`ItemHolder::UseObject` (`pc:402923`, `0x00588a80`) is the client function +every use-item entry point calls (`ClientUISystem::UseObject`, +`ItemHolder::UseObject` at the SmartBox/selection sites, the toolbar Use +button). Reading it in full: it does a 0.2s spam-throttle check +(`m_timeLastUsed`), a busy-request check +(`ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest`), and a series of +use-legality checks (trade-locked, wield-required, PK-altar confirmation) — +**there is no distance/range check anywhere in this function.** On the +success path it calls `CM_Inventory::Event_UseEvent(arg1)` unconditionally +(`pc:403043`) and shows the status text `"Approaching %s"` when the target's +`InqType() & 0x10` bit is set (`pc:403047-403051`) — that string is passive +UI feedback reacting to the send, not a gate on it. + +acdream's equivalent send path, `SelectionInteractionController.RequestUse` +(`src/AcDream.App/Interaction/SelectionInteractionController.cs:217-235`), +matches this exactly: it calls `CancelPendingApproach()` then dispatches +`_transactions.TryDispatchUse(...)` immediately — **no `TryGetApproach`/range +check precedes it**, unlike the sibling `RequestPickup` method in the same +file (below). Both clients send Use unconditionally regardless of distance. + +### (b) ACE's server-side range enforcement and move-to + +`WorldObject.IsWithinUseRadiusOf` (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Use.cs:47-55`): + +```csharp +public bool IsWithinUseRadiusOf(WorldObject wo, float? useRadius = null) +{ + if (useRadius == null) useRadius = wo.UseRadius ?? 0.6f; + var cylDist = GetCylinderDistance(wo); + return cylDist <= useRadius; +} +``` + +`0.6f` is the fallback ONLY for objects with no authored `UseRadius`. A +vendor NPC's actual `UseRadius` is whatever its weenie's `PropertyFloat.UseRadius` +is authored to (typically several meters for an NPC, not the 0.6f ground-item +fallback) — the "very close range" symptom is not explained by this fallback +alone. + +**The actual mechanism ("walk to it") lives in `Player.HandleActionUseItem`** +(`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`): + +```csharp +if (item.CurrentLandblock != null && !item.Visibility && item.Guid != LastOpenedContainerId) +{ + if (IsBusy) { SendUseDoneEvent(WeenieError.YoureTooBusy); return; } + CreateMoveToChain(item, (success) => TryUseItem(item, success)); +} +else + TryUseItem(item); +``` + +`CreateMoveToChain` (`Player_Move.cs:37-65`) checks `CurrentLandblock.WithinUseRadius` +first; if already in range it just rotates the player toward the target and +fires the callback. **If NOT in range, it physically walks the player there** +via the server's own `MoveToManager`/physics — this is a real, gradual, +pathed walk broadcast to every observer (including the mover's own client) +as ordinary motion, not a teleport. + +`Vendor.ActOnUse`'s own doc comment makes the contract explicit +(`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:223-228`): + +> "This is raised by `Player.HandleActionUseItem`. **If the item was outside +> of range, the player will have been commanded to move using DoMoveTo +> before `ActOnUse` is called. When this is called, it should be assumed +> that the player is within range.**" + +**Conclusion: ACE unconditionally walks the player to a distant vendor +before opening it — there is no server-side range REJECTION for a normal +Use, only a walk-then-open.** + +### (c) Retail's client has no LOCAL prediction of this walk; acdream has one, but not wired to Use + +Tracing how the server's move-to becomes visible: the `MoveToObject` motion +command a `CreateMoveToChain` walk produces is unpacked on the RECEIVE side +by `MovementManager::HandleNetMotion`-family code (`pc:300628-300647`, case +`6` of the `UIQueueManager` motion-command switch) via +`MovementParameters::UnPackNet(¶ms, MoveToObject, ...)` → +`CPhysicsObj::MoveToObject(...)` — this is the SAME wire-driven receive path +used for ANY entity's broadcast motion (NPCs, other players). Retail's +client does not pre-emptively simulate the walk from the `ItemHolder::UseObject` +call site itself (confirmed above — no local movement issued there); it only +starts visibly walking once the server's motion broadcast arrives, exactly +like watching any other entity walk. + +acdream's `PlayerInteractionMovementSink.BeginApproach` +(`src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs:24-70`) is a +**client-predicted** move-to-target primitive — its own doc comment says so +verbatim: *"Installs retail's client-side TurnToObject/MoveToObject +prediction through the same MovementManager used by authoritative movement +packets."* It builds a `MovementStruct` with +`Type = approach.IsCloseRange ? MovementType.TurnToObject : MovementType.MoveToObject` +and installs it on `PlayerMovementController.MoveTo` directly — this is a +REAL, already-working local walk animation. + +**But this mechanism is wired ONLY to pickup.** +`SelectionInteractionController.RequestPickup` (lines ~300-372) calls +`_query.TryGetApproach(itemGuid, out approach)` then +`_movement.BeginApproach(approach, ...)` before dispatching the pickup wire +message. `RequestUse` (lines 217-235, quoted in (a) above) has no equivalent +call — Use is sent with zero client-side approach handling, relying entirely +on ACE's server-driven walk-and-broadcast to eventually move the player and +open the vendor. + +### What this means for "opens only at very close range" + +Two distinct, evidenced possibilities, presented in order of how directly +they're supported by what was read in this pass: + +1. **Missing local prediction is a cosmetic gap, not a functional one.** + Since ACE's `CreateMoveToChain` is unconditional and server-authoritative, + a distant vendor Use SHOULD still eventually open once the server's walk + completes and broadcasts back — acdream's local player движение pipeline + would need to correctly apply that INCOMING broadcast motion to itself. + `RuntimeLiveEntitySessionController.OnMotionUpdated` + (`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:211-222`) + DOES explicitly special-case the local player's own guid + (`bool isLocal = update.Guid == _runtime.PlayerIdentity.ServerGuid;`), so + this is not a silent guid-filter drop — but this pass did not trace all + the way to the visual/physics rendering of that update to confirm the + walk is actually SEEN by the user. If it is not rendered (or is rendered + but janky/instant), the user's experience would be "nothing visibly + happens unless I'm already close" even though the server is doing the + right thing. +2. **Porting the same client-predicted approach acdream already has for + pickup onto Use** (calling `_movement.BeginApproach` before + `RequestUse`'s dispatch, mirroring `RequestPickup`) would give Use the + SAME responsive, immediately-visible walk pickup already has, matching + the retail FEEL even though — per (c) above — it is technically MORE + client-prediction than retail's own client does for Use specifically. + This would need to be flagged as a deliberate acdream enhancement over + the byte-literal retail mechanism (per the project's "flag the tradeoff" + rule for redesigns), not silently added — but it directly and + unambiguously fixes the reported symptom regardless of which hypothesis + in (1) is true, since it makes the walk client-visible immediately + instead of only after a server round-trip. + +**Recommendation:** since this is a UX/movement-authority area with a +documented project history of reverted prediction campaigns (CLAUDE.md's +Modern Runtime section), do NOT silently redesign. The narrower, safer first +step is verifying hypothesis (1) live (does the character visibly walk at +all when Use is sent from range, however slowly) before deciding whether (2) +is warranted as a deliberate enhancement. + +--- + +## Q3 — "Add to list" (buy staging) semantics + +**Short answer: retail's Buy button is a real, self-contained, immediate +purchase (`BuySingleItem`, already ported in Slice 6) that does not need +staging to exist. Staging is a SEPARATE, purely-client-local mechanism (the +"Buying" tab, `VendorBuyUI`) that batches multiple picks into one `0x005F` +call. Every button on both the "Items" and "Buying" tabs is now fully traced +below with exact retail addresses.** + +All four staging-affecting buttons are cases inside +`gmVendorUI::HandleButtonClicks` (`pc:203950-204184`, `0x004c50d0`), the +single dispatcher for every vendor-panel button click (id → case is a direct +switch on the authored element id from the D0 layout tree in the Slice 5 +research doc §B.4). + +### Add to List — `0x100000C3` ("Items" tab) + +``` +case 0x100000c3: +{ + ACCWeenieObject* item = ClientObjMaintSystem::GetWeenieObject(selectedID); + if (item != 0) + { + if (item->pwd._stackSize <= 1) + VendorItemsUI::AddToBuyList(m_itemsUI, item, 1); + else + VendorItemsUI::AddToBuyList(m_itemsUI, item, GetObjectSplitSize(item)); + } + break; +} +``` + +(`pc:203970-203988`.) Reads the GLOBALLY-selected item, computes quantity +from the item's own stack size (1 for non-stackable) or the CURRENT toolbar +slider value (`ItemHolder::GetObjectSplitSize`, `pc:401465-401477`, +`0x00586F00`) for a stackable one, and calls +`VendorItemsUI::AddToBuyList(m_itemsUI, item, quantity)`. **This sends +NOTHING to the server** — it appends one entry (item + quantity) into +`gmVendorUI::m_buyList`, a `PackableList` that backs the +"Buying" tab's `m_buyShopList` widget (`0x100000C5` in the D0 tree). + +### Staged-entry rendering + +`gmVendorUI::RecordContents(this, srcList, dstProfileList, arg4, arg5)` +(`pc:200541-200718+`) is the sync function that walks a UI item-list widget's +CURRENT contents and rebuilds a `PackableList` from it (used +both to sync `m_buyShopList`'s displayed rows back into `m_buyList` before a +transaction, and symmetrically for `m_sellShopList`/`m_sellList`). Per-row +count/price display for the "Buying" tab mirrors the "Items" tab's own +name+price computation (Slice 6 research doc §B.1, `VendorPricing.SellPrice` +already ported in acdream) — no new pricing formula is needed for staging, +only a second row-rendering pass over `m_buyList`'s entries instead of +`shopItemProfileList`. + +### "Buy Item" — `0x100000C9` ("Buying" tab) + +``` +case 0x100000c9: +{ + // stackable = maxStackSize > 1 for the selected item + if (gmVendorUI::BuySingleItem(this, selectedID) != 0) + { + int amount = stackable ? -1 : 1; + gmVendorUI::RemoveProfileFromList(this, &m_buyList, selectedID, amount); + VendorBuyUI::Update(m_buyUI); + } + break; +} +``` + +(`pc:203989-204010`.) Buys the currently-selected item using the SAME +`BuySingleItem` path the "Items" tab's Buy button uses (already fully ported +in Slice 6 — B.1 of the transactions research doc) — it reads the GLOBAL +slider quantity, not the staged entry's own quantity. **On success only**, +removes the entry from `m_buyList` via `RemoveProfileFromList` and repaints. + +### `RemoveProfileFromList` semantics (shared by both tabs' single-item removal) + +`gmVendorUI::RemoveProfileFromList(this, list, itemGuid, amount)` +(`pc:200497-200537`, `0x004c1260`): finds the matching entry by guid; if +`amount == -1` (0xFFFFFFFF) OR `amount >= the entry's staged quantity`, +**removes the whole entry**; otherwise decrements the entry's staged +quantity by `amount` and keeps the (now-smaller) entry. Every call site in +`HandleButtonClicks` passes either `1` (non-stackable — a lone quantity-1 +entry is always fully consumed by decrementing 1) or `-1` (stackable — the +whole staged batch is always bought/removed in one action; there is no +"buy 3 of the staged 10" partial-consume UI). **Net effect: both branches +always remove the entire staged entry in practice** — the 1-vs- -1 split in +the caller is really about correctness for edge cases (a staged quantity of +exactly 1 unit on an item whose `maxStackSize` happens to be >1), not a +user-visible partial-buy feature. + +### "Buy All" — `0x100000CA` ("Buying" tab) + +``` +case 0x100000ca: +{ + // pyreal vendor: check m_buyUI->m_transactionValue <= m_totalValue + // alt-currency vendor: check m_transactionValue <= (trade_num - m_last_sale) + // — either failing shows a transient error string and returns. + + RecordContents(this, m_buyUI->m_buyShopList, &m_buyList, 1, 1); // sync UI → list + InqListSlotCount(this, &m_buyList, &itemSlotsNeeded, &containerSlotsNeeded); + // capacity check against the player's free item/container slots + // — failing shows a transient error string and returns. + + SendShopEvent(this, shopVendorID, &m_buyList, currencyId, SE_BUY); // → Event_Buy (0x005F) + PackableList::Flush(&m_buyList); // clear staging + VendorBuyUI::Update(m_buyUI); +} +``` + +(`pc:204011-204079`.) This is the ONE path that actually sends a +**multi-item** `Event_Buy` — the whole staged list in a single wire call, +matching the already-decoded `0x005F` payload's `itemCount` + per-item +`(amount, guid)` array (Slice 6 research doc §A.1). On success the ENTIRE +staging list is flushed unconditionally. + +### "Clear Item" / "Clear List" — `0x100000CB` / `0x100000CC` + +Clear Item (`pc:204080-204094`) is the exact same +`RemoveProfileFromList(this, &m_buyList, selectedID, amount)` call as "Buy +Item" but WITHOUT calling `BuySingleItem` first — pure removal, no +transaction. Clear List (`pc:204095-204100`) is an unconditional +`PackableList::Flush(&m_buyList)` — clears everything staged, +no transaction, no per-item check. + +### What else clears staging — the close-button interaction (new finding) + +**`0x100000D6` (the panel's X/close button) is NOT an unconditional hide +when staging is non-empty.** Full case (`pc:204147-204181`): + +``` +case 0x100000d6: +{ + if (m_buyList.head == 0 && m_sellList.head == 0) + { + SetVisible(0); // plain hide — nothing staged + return; + } + if (m_curDialogContext == 0) + // show a confirm dialog: "You have not completed all transactions..." + // (DialogFactory::MakeCallbackDialogInCurrentUI, callback = + // gmVendorUI::CloseVendorDialogCallback) + break; +} +``` + +Retail's vendor X button **refuses to close and shows a confirmation dialog +if either staging list is non-empty.** acdream's current +`CloseButtonPressed` (`src/AcDream.App/UI/Layout/VendorUiController.cs:1180`, +`=> _window.Hide();`) is a plain unconditional hide — **this is correct +TODAY only because staging is always empty** (no Buying/Selling staging +exists yet in acdream), matching the `pc:204147-204152` branch exactly. +**Once Buying-tab staging (this section) or Selling-tab staging (Q4) lands, +`CloseButtonPressed` needs the same non-empty-staging gate**, or a purchase +a user staged but never confirmed will silently vanish on window close with +no retail-authentic warning. `src/AcDream.App/UI/Layout/RetailDialogFactory.cs` +already exists as confirm-dialog infrastructure to reuse for this. + +--- + +## Q4 — selling: the full retail flow + +**Short answer: the drop target for selling is specifically the "Selling" +tab's staged list widget (`m_sellShopList`, id `0x100000CE`) — NOT the +vendor NPC in the 3D world, and NOT the default-open "Items" tab. Dropping +onto anything else is correctly rejected (the user's "red no-drop marker" is +retail-accurate for every current acdream drop target, since acdream's +vendor panel has ZERO drag-handler wiring today). `InqAcceptability` gates +BOTH the drop AND its hover-preview coloring, with four distinct +retail-authored rejection messages.** + +### The drop-target gate (previously undocumented) + +`gmVendorUI::HandleDropRelease` (`pc:204229-204246`, `0x004c5680`) is the +WHOLE PANEL's drop-release handler — every drag release anywhere inside the +vendor window routes through this one function first: + +```cpp +void gmVendorUI::HandleDropRelease(this, msgInfo) +{ + if (source != 0 && target != 0 + && UIElement::IsAncestorOfMe(target, m_sellUI->m_sellShopList) != 0) + { + InqDropIconInfo(source, &info, &flags); + if (info != 0 && (flags & 0xe) == 0) + VendorSellUI::AcceptDragObject(m_sellUI, info); + } +} +``` + +**The gate is `IsAncestorOfMe(target, m_sellShopList)`** — the drop target +element must BE (or be a descendant of) the "Selling" tab's staged-item list +specifically. Dropping on the "Items" tab (the tab that's actually visible +by default when you approach a vendor), the vendor's name/portrait, or +anywhere else in the window is a structural no-op at this gate — it never +even reaches `AcceptDragObject`. **This is the exact mechanism behind the +user's observation**: dragging toward "the vendor" in the sense of the +window/NPC generally has never been retail's mechanism; you must first +switch to the "Selling" tab, then drop specifically onto its list. + +### `VendorProfile::InqAcceptability` — what the vendor accepts + +`VendorProfile::InqAcceptability(profile, pwd)` (`pc:484768-484797`, +`0x005d1a90`): + +```cpp +uint32_t InqAcceptability(profile, pwd) +{ + if ((pwd->_type & profile->item_types) == 0 || (pwd's "non-sellable" bit set)) + return profile->item_types; // wrong item type (or explicitly non-sellable) + + value = pwd->_stackSize > 0 ? pwd->_value / pwd->_stackSize : pwd->_value; // per-unit value + if (value == 0) return 2; // "has no value" + if (profile->max_value != -1 && value > profile->max_value) return 4-ish; // "too valuable" + if (profile->min_value != -1 && value < profile->min_value) return 3; // "too cheap" + return 0; // acceptable +} +``` + +(Bit-test on the "too valuable" branch is a BinaryNinja-decompiler artifact +— `(!((_type >> 0x10)) & 4)` reduces to either 0 or 4 depending on a type +flag bit; treat the RETURN CODE, not the exact expression, as the citation.) +`VendorProfile::IsAcceptable` (`pc:484817-484822`, `0x005d1b50`) is the +boolean wrapper: `true` iff `InqAcceptability(...) == 0`. + +`VendorSellUI::DragItemAcceptable(this, itemGuid, silent)` +(`pc:201195-201307`, `0x004c20c0`) is what actually calls +`InqAcceptability` for a drag candidate, layered with two PRIOR checks: + +1. **Must be owned by the player** (`ACCWeenieObject::IsOwnedByPlayer`) — + else (when not silent) shows *"You can only sell items you are..."* and + rejects. +2. **A non-empty container is always accepted** + (`GetNumContainedItems(item) > 0` → return 1) — a bag with stuff in it + bypasses the type/value filter entirely (sell the whole bag, contents and + all). +3. Otherwise defers to `InqAcceptability`, mapping its result to one of four + retail-authored strings when NOT silent: + `1` → *"That item cannot be sold here"*, `2` → *"That item has no value + and cannot..."*, `3` → *"That item is too cheap to sell here"*, `4` → + *"That item is too valuable to sell here"*; any other nonzero value (the + common case for a genuine type mismatch, since `InqAcceptability` returns + the raw `item_types` bitmask, not a small integer) falls through to the + generic *"You cannot sell that here."* + +**The `silent` argument is the hover-vs-release distinction**: +`VendorSellUI::OnItemListDragOver` (`pc:201320-201339`) calls +`DragItemAcceptable(this, guid, /*silent*/ 1)` on every drag-hover frame, +using ONLY the boolean result to set the drag-accept cursor state +(`SetDragAcceptState(0x10000040)` green / `0x10000041` red — no message +spam while merely hovering). `VendorSellUI::AcceptDragObject` +(`pc:203866-203905`, `0x004c4f00`) calls `DragItemAcceptable(this, guid, +/*silent*/ 0)` on the actual drop, which DOES show the rejection string. + +### `AddItemToSell` — what a successful drop does + +`VendorSellUI::AddItemToSell(this, itemGuid)` (`pc:203546-203567`, +`0x004c4a20`): + +```cpp +void AddItemToSell(this, itemGuid) +{ + m_parent->m_last_sale = 0; + UIElement_Panel::OpenTab(m_vendorPanel, 0x100000bb); // ← auto-switches to "Selling" tab + ACCWeenieObject::SetSelectedObject(itemGuid, 0); // ← globally selects the dropped item + gmVendorUI::AddItem(m_parent, m_sellShopList, itemGuid, -1, 1, 1, 0, 1, -1); + gmVendorUI::RecordContents(m_parent, m_sellShopList, &m_parent->m_sellList, 0, 1); + gmVendorUI::AdoptAsContents(m_parent, m_sellShopList, &m_parent->m_sellList, 1); + VendorSellUI::UpdateSellUI(this); + VendorSellUI::UpdateTransactionValue(this); + VendorSellUI::UpdateTotalValue(this); +} +``` + +A successful drop **auto-navigates the panel to the "Selling" tab** (so the +staged item becomes visible immediately even though the drop itself +happened while "Items" was open — this reconciles with the drop TARGET +being `m_sellShopList`, which is only mounted as the "Selling" tab's page; +the widget can receive a drop event even while its page isn't the visually +active one), selects the item globally (same primitive +`SelectionState.Select` already threads through the rest of the vendor UI +per the Slice 6 research doc §B.4/§C.1), inserts a row, syncs to +`m_sellList`, and refreshes the price/total displays. + +### Sell — the `0x0060` payload and reconciliation + +Already fully decoded in the Slice 6 research doc §A.3: `GameActionType.Sell += 0x0060`, handler `GameActionSellItems.Handle` → `Player.HandleActionSellItem` +(`Player_Commerce.cs:126-226`) — a structural mirror of buy (per-item +validation via `VerifySellItems`, payout via +`Vendor.CalculatePayoutCoinAmount`/`GetBuyCost`, pack-space check, item +removal + `GameEventItemServerSaysContainId`, `vendor.ProcessItemsForPurchase`, +coin-stack creation, `GameMessageSound`, unconditional `SendUseDoneEvent()` +at the end — same `UseDone` completion signal Q1/A.4 of the prior research +doc already established for Buy). Retail's `"Sell All"` button +(`0x100000D3`, `pc:204113-204129`) is the wire-sending path — it calls +`RecordContents` to sync the UI list into `m_sellList`, then directly +`CM_Vendor::Event_Sell(shopVendorID, &m_sellList)` (2-arg, no trailing +currency field, matching the prior doc's Sell-vs-Buy asymmetry finding) — +**there is no per-item "Sell Item" wire path distinct from "Sell All"** in +the sense Buy has one: `0x100000D2` ("Sell Item") calls `SellSingleItem` +(an immediate, non-staged sell of the globally-selected item, symmetric to +`BuySingleItem`) and then removes that one entry from staging — it does not +send the STAGED entry's own wire request; it's the same +immediate-single-item pattern Buy's `0x100000C2` uses. `0x100000D4`/`D5` +("Clear Item"/"Clear List") mirror the buy-side clear buttons exactly, +additionally calling `gmVendorUI::FlushSellListSellState` (clears each +cleared item's "pending sell" visual highlight in the player's OWN inventory +panel, `VendorItemSetSellState`). + +### acdream's existing drag/drop pattern to reuse + +`VendorUiController` implements `IRetainedPanelController` but **does not +implement `IItemListDragHandler` at all** — grepping the whole file confirms +zero drag/drop wiring exists today. This is exactly why any drag toward the +vendor window shows the red no-drop marker: no controller opted a target +list into accepting anything. + +The reusable pattern already lives in `ExternalContainerController` +(`src/AcDream.App/UI/Layout/ExternalContainerController.cs:205-263`), which +implements `IItemListDragHandler`'s three methods: + +```csharp +public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) +{ + if (!ReferenceEquals(targetList, _contentsList) || ...) + return ItemDragAcceptance.Reject; + return ItemDragAcceptance.Accept; +} +``` + +`UiItemList.RegisterDragHandler(IItemListDragHandler)` +(`src/AcDream.App/UI/UiItemList.cs:137-141`) is how a specific list widget +opts into a handler. **This is the direct structural analogue of retail's +`IsAncestorOfMe(target, m_sellShopList)` gate** — a future `VendorSellUI`- +equivalent staging controller would register itself as the drag handler on +the "Selling" tab's `UiItemList` (`0x100000CE`) specifically, reject drops +on every other target the way `ExternalContainerController.OnDragOver` +already rejects drops on anything but `_contentsList`, and call +`_selection.Select(...)` + a local staging-list append (mirroring +`AddItemToSell`) on acceptance — no new drag-and-drop infrastructure is +needed, only a new participant in the existing one. + +--- + +## Q5 — the stacked-item status bar + +**Short answer: retail's toolbar strip NEVER shows a price/value suffix in +the object name — only "{count} {name}" — so a "(250,000)" total-value +figure belongs to the VENDOR ROW's own price text (already implemented), +not the toolbar. Retail's slider seeding Trade Notes to "1" (not 250) is +CORRECT retail behavior (PromissoryNote is inside the vendor split-exempt +mask). Reading the current source, the whole mechanism — name formatting, +slider visibility, vendor-exempt seeding, and the materializer feeding +correct data into it — already appears fully implemented and correctly +wired. No code-level gap was found in this pass; see the closing note.** + +### Retail's exact toolbar presentation, decoded field-by-field + +`gmToolbarUI::HandleSelectionChanged` (`pc:198635-198834`, `0x004bf380`) is +read here in full for the first time (the prior Slice 6 doc's §B.3 only +covered the slider-visibility half). The function has three distinct name- +text branches, gated first on whether the selection is the player's OWN +pyreal coin stack: + +1. **Player-owned coinstack** (`pc:198712-198738`): a separate formatted + string reads a `CBaseQualities::InqInt(..., 0x14, ...)` value (a player- + module integer property) — this branch is specific to the player's own + held pyreals and does not apply to vendor merchandise of any kind + (Trade Notes are `PromissoryNote` type, never `IsCoinstack`). +2. **Everything else, stack size ≤ 1** (`pc:198691-198700`): plain + `GetObjectName(item, NAME_APPROPRIATE, 0)` — just the name, no count, no + price. +3. **Everything else, stack size > 1** (`pc:198701-198710`): a formatted + string composing `"{stackSize} {name}"` — **and nothing else**. There is + no third parameter, no value, no price anywhere in this branch's format + call. + +**Retail's toolbar name text for a 250-stack of Trade Notes is literally +"250 Trade Notes" — no parenthetical anything.** If a "(250,000)" figure is +expected to appear near the selection, it is not part of this element; it +is the VENDOR ROW's own price text, a completely separate widget +(`m_itemCostText`, `0x100000C1`, per the Slice 5 doc's D0 tree) that already +exists. + +### The slider-seed mask, and why "1" for Trade Notes is correct + +Continuing the same function (`pc:198767-198821`), for a stack > 1 the +vendor-owned branch (already partially cited in the prior doc) is: + +```cpp +if (vendorID != 0 && item->pwd._containerID == vendorID + && (item->InqType() & 0xdc41cb0) != 0) + seed = 1; +else + seed = item->pwd._stackSize; +GenItemHolder::splitSize = seed; +GenItemHolder::maxSplitSize = item->pwd._stackSize; +``` + +`PromissoryNote = 0x40000` (from the Slice 5 doc's category table) **is +inside** the mask `0xDC41CB0` (`0x40000 & 0xDC41CB0 == 0x40000`, verified +by direct computation). **A 250-stack of vendor-owned Trade Notes therefore +seeds the slider to 1 in genuine, byte-verified retail — not 250.** This is +the intentional "you're buying from open-ended stock; choose a quantity" +UX, not a bug. If the user's screenshot showed the slider at "1", that +matches retail exactly. + +### acdream's current implementation, traced end to end + +1. **Name formatting** — `SelectedObjectController.ApplySelection` + (`src/AcDream.App/UI/Layout/SelectedObjectController.cs:340-347`): + ```csharp + uint stackSize = _stackSize(g); + string? objectName = _resolveName(g); + _currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) + ? $"{stackSize} {objectName}" + : objectName; + ``` + Matches retail branch 3 exactly — no value suffix, matching retail's own + absence of one. + +2. **Slider visibility + vendor-exempt seeding** + (`SelectedObjectController.cs:368-374`): + ```csharp + if (stackSize > 1u) + { + uint seed = _isVendorSplitExempt(g) ? 1u : stackSize; + _splitQuantity.Reset(stackSize, initialValue: seed); + if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; + if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; + } + ``` + Matches retail's seed/visibility logic exactly, including the + maxSplitSize-vs-seed distinction the prior doc already flagged. + +3. **The vendor-exempt predicate** is `VendorSplitPolicy.IsSplitExempt` + (`src/AcDream.Core/Items/VendorSplitPolicy.cs`) — `SplitExemptMask = + 0x0DC41CB0u` (the exact retail literal), used as the SINGLE source of + truth by both `SelectedObjectController` (via + `IsVendorSplitExempt` in `InteractionRetainedUiComposition.cs:682-686`) + and `VendorUiController.ResolveBuyQuantity` (the row-level display). + This already resolved the Slice 6 research doc's open question #3 + ("where should the mask live") in favor of a single shared class — there + is no second copy to reconcile. + +4. **The data source** — `VendorShopItemMaterializer.ToWeenieData` + (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs:237-260`) + writes `StackSize: item.DescStackSize` into `ClientObjectTable` — the + PublicWeenieDesc's own per-unit stack size (the wire equivalent of an + ordinary CreateObject's StackSize), NOT `VendorShopItem.StackSize` + (ItemProfile's separate packed SUPPLY-count field, which can be `-1` for + unlimited stock). The doc comment explicitly calls out this exact + distinction. `_stackSize` at the composition root + (`InteractionRetainedUiComposition.cs:663-664`, + `guid => (uint)(d.Inventory.Objects.Get(guid)?.StackSize ?? 0)`) reads + this same field. + +**Every link — materializer field mapping, name formatting, slider +visibility, vendor-exempt seed source, shared mask policy — traces +correctly and consistently to retail's decomp on paper.** This document +found no missing piece. + +### Closing note for the contract + +Given (a) the retail decomp shows NO value suffix belongs in this element at +all, and (b) every piece of acdream's current implementation already matches +retail's mechanism when read from source, the most likely explanations for +the reported gap are, in order of likelihood: + +1. **The screenshot's "(250,000)" is the vendor ROW's own price text** + (`_itemCostText`), which the user (reasonably, given both are near each + other on screen when a vendor row is selected) is reading together with + the toolbar strip as "the status bar." If so, there is no code gap here + at all — both pieces already work as retail does, just as two separate + elements, exactly like retail. +2. **A genuinely runtime-only defect** (draw-order, a stale/never-refreshed + `UiText`, or a session predating `97cf8738`/`3c9fc57a`/`5224e438`) that + static reading cannot surface. Since this document is read-only research, + **the concrete next step is a live re-test against current HEAD before + writing any code** — re-implementing an already-correct mechanism because + an old screenshot predates the fix would be wasted, retail-divergent + effort. + +--- + +## Scope recommendation + +Ordered by dependency; each step either has zero prerequisites among the +others or is explicitly marked with what it needs first. + +1. **Q5 (status bar) — verify live, do not implement yet.** Every piece + traced correctly from source; the fastest path is a live re-test against + current HEAD. If it turns out to already work, this item is a no-op. If a + genuine runtime bug remains, it is narrow (one of: draw refresh, a stale + session, or a single wiring line) and should be diagnosed with a live + trace rather than guessed at from more static reading. + +2. **Q1 (insert position) — verify live before touching code.** Same + reasoning: the entire chain already matches retail's server-sourced + position-0 insert. The one unruled-out theory (stack-merge bypassing + `ContainId` entirely) is falsifiable with one live buy of a fresh item + plus a breakpoint/log on `ApplyConfirmedServerMove`. Do this before any + edit — the risk of "fixing" already-correct code by guessing is real + here (a mechanism this well-cited being wrong would be surprising). + +3. **Q2 (use-range feel) — a genuine, scoped implementation candidate, + independent of the others.** Two sub-steps: (a) confirm live whether the + server-driven walk is visible at all today (cheap, no code); (b) if not, + port `PlayerInteractionMovementSink.BeginApproach` onto `RequestUse` the + same way `RequestPickup` already uses it — a bounded, single-file change + with a clear precedent to copy. Flag it explicitly as an acdream + enhancement over retail's own client (which has no local Use prediction) + if pursued, per the project's "flag tradeoffs on redesigns" rule. + +4. **Q3 (buy staging) — self-contained, no dependency on Q4.** The full + mechanism (`AddToBuyList`, `RemoveProfileFromList`'s two removal shapes, + `Buy All`'s batched `0x005F` send, `Clear Item`/`Clear List`) is fully + traced above with exact addresses and needs no new infrastructure beyond + a `VendorBuyUI`-equivalent staging list controller and wiring the + "Buying" tab's five buttons (already-mounted-but-inert per the Slice 5 + doc). **Carries one small dependency OUT**: once this lands, the X-close + button (`CloseButtonPressed`) needs the non-empty-staging confirm-dialog + gate described in Q3's closing subsection — a one-line follow-up to + `VendorUiController.cs`, not a blocker to starting. + +5. **Q4 (sell staging + drag) — shares the staging-list rendering shape + with Q3 but needs its own drag/drop wiring.** The concrete new pieces are + (a) a `VendorSellUI`-equivalent staging controller implementing + `IItemListDragHandler` and registering on the "Selling" tab's list + specifically (mirroring `ExternalContainerController`'s existing + pattern), (b) `InqAcceptability`'s four-way rejection-message mapping + (type/no-value/too-cheap/too-valuable, already fully decoded above), (c) + the same X-close confirm-dialog dependency as Q3. Building Q3 first is + not strictly required, but doing so first lets Q4 reuse whatever shared + staging-list rendering scaffolding (row count/price display, `Clear + Item`/`Clear List` button plumbing) Q3 establishes rather than each + inventing its own. + +**Suggested order: Q5 verify → Q1 verify → Q2 implement → Q3 implement → Q4 +implement (reusing Q3's scaffolding) → the shared X-close confirm-dialog +follow-up once at least one of Q3/Q4 has landed.** + +## Open questions for the contract + +1. **Q2**: is the missing piece "acdream doesn't render the server's forced + walk" or "acdream never predicts it locally"? Only a live trace resolves + which hypothesis is true — the fix differs (a rendering bug vs. a + deliberate new client-prediction feature). +2. **Q3/Q4 shared**: should the "Buying" and "Selling" tabs' staging-list + controllers be two independent classes, or one generic + `VendorStagingListController` parameterized by tab/list ids and a + drag-acceptance predicate? Retail itself has two nearly-parallel classes + (`VendorBuyUI`/`VendorSellUI`) with a shared base + (`VendorSubUI::HandleSetSelectedItem`, cited in the Slice 6 research + doc §B.4) — a shared acdream base class matching that shape is a + defensible starting point, not dictated by this research. +3. **Q4**: `InqAcceptability`'s literal return-value `1` ("cannot be sold + here") appears effectively unreachable in practice, since a genuine + type mismatch returns the raw `item_types` bitmask (almost never + literally `1`), falling instead through `DragItemAcceptable`'s `> 3` + generic-message branch. Port the exact retail control flow anyway (it + costs nothing and stays byte-faithful) rather than "simplifying" the + switch — per CLAUDE.md's "do not fix the decompiled code" rule.