docs(plan): Slice 6b/6c vendor-completion contract — move-to-use, staging, selling, status-bar axiom

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 11:00:50 +02:00
parent 33b45ee581
commit ab3146ba88
2 changed files with 938 additions and 0 deletions

View file

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

View file

@ -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(&params, 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<ItemProfile>` 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<ItemProfile>` 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<ItemProfile>::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<ItemProfile>::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<T>` 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.