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 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):
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 beforeActOnUseis 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:
- Missing local prediction is a cosmetic gap, not a functional one.
Since ACE's
CreateMoveToChainis 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. - Porting the same client-predicted approach acdream already has for
pickup onto Use (calling
_movement.BeginApproachbeforeRequestUse's dispatch, mirroringRequestPickup) 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:
- Must be owned by the player (
ACCWeenieObject::IsOwnedByPlayer) — else (when not silent) shows "You can only sell items you are..." and rejects. - 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). - 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, sinceInqAcceptabilityreturns the rawitem_typesbitmask, 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.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:
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:
- Player-owned coinstack (
pc:198712-198738): a separate formatted string reads aCBaseQualities::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 arePromissoryNotetype, neverIsCoinstack). - Everything else, stack size ≤ 1 (
pc:198691-198700): plainGetObjectName(item, NAME_APPROPRIATE, 0)— just the name, no count, no price. - 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
-
Name formatting —
SelectedObjectController.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.
-
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.
-
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 bothSelectedObjectController(viaIsVendorSplitExemptinInteractionRetainedUiComposition.cs:682-686) andVendorUiController.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. -
The data source —
VendorShopItemMaterializer.ToWeenieData(src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs:237-260) writesStackSize: item.DescStackSizeintoClientObjectTable— the PublicWeenieDesc's own per-unit stack size (the wire equivalent of an ordinary CreateObject's StackSize), NOTVendorShopItem.StackSize(ItemProfile's separate packed SUPPLY-count field, which can be-1for unlimited stock). The doc comment explicitly calls out this exact distinction._stackSizeat 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:
- 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. - A genuinely runtime-only defect (draw-order, a stale/never-refreshed
UiText, or a session predating97cf8738/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.
-
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.
-
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
ContainIdentirely) is falsifiable with one live buy of a fresh item plus a breakpoint/log onApplyConfirmedServerMove. 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). -
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.BeginApproachontoRequestUsethe same wayRequestPickupalready 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. -
Q3 (buy staging) — self-contained, no dependency on Q4. The full mechanism (
AddToBuyList,RemoveProfileFromList's two removal shapes,Buy All's batched0x005Fsend,Clear Item/Clear List) is fully traced above with exact addresses and needs no new infrastructure beyond aVendorBuyUI-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 toVendorUiController.cs, not a blocker to starting. -
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 implementingIItemListDragHandlerand registering on the "Selling" tab's list specifically (mirroringExternalContainerController'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 Listbutton 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
- 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).
- 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. - Q4:
InqAcceptability's literal return-value1("cannot be sold here") appears effectively unreachable in practice, since a genuine type mismatch returns the rawitem_typesbitmask (almost never literally1), falling instead throughDragItemAcceptable's> 3generic-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.