acdream/docs/research/2026-08-08-slice6b-vendor-completion-research.md
2026-08-08 11:00:50 +02:00

44 KiB

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):

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):

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:

/// 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 ApplyServerMoveApplyPlacement(..., retailContainerInsert: true) (ClientObjectTable.cs:380-437), which is called directly from the InventoryPutObjInContainer (0x0022) wire handler (src/AcDream.Core.Net/GameEventWiring.cs:356-367):

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):

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):

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:

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):

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):

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.HandlePlayer.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:

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:

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 formattingSelectedObjectController.ApplySelection (src/AcDream.App/UI/Layout/SelectedObjectController.cs:340-347):

    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):

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