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