893 lines
44 KiB
Markdown
893 lines
44 KiB
Markdown
# Slice 6b/6c vendor-completion research — staging, selling, and three live-gate gaps
|
|
|
|
**Date:** 2026-08-08
|
|
**Trigger:** the user's live connected gate on the Slice 6 buy arc (`97cf8738`,
|
|
`3c9fc57a`, `5224e438`) surfaced three residual mechanism gaps (bought items
|
|
land last, vendor use-range feels too tight, the stacked-item status bar is
|
|
missing pieces) plus the two fenced-out features (Buying-tab staging,
|
|
Selling-tab drag-to-sell). This document answers all three gaps and both
|
|
staging mechanisms with retail citations, so the implementer can pick up
|
|
Slice 6b (staging) and 6c (selling) without re-deriving the decomp.
|
|
|
|
**Verified starting point:** repo HEAD at research time was `5224e438`
|
|
("fix(vendor): gate-findings pass — the X button HIDES like retail, clicks
|
|
return, the dropdown scrolls, pyreal suffix, staged-tab slots"). Read-only
|
|
research; no code changed. `src/AcDream.App/UI/Layout/UiMenu.cs` and
|
|
`VendorUiController.cs` are owned by a parallel implementer in this session —
|
|
both were read in full for this document but are cited, not edited.
|
|
|
|
**Mandatory prior reading done first:**
|
|
`docs/research/2026-08-08-slice5-vendor-browse-research.md` (browse lifecycle,
|
|
the D0 layout tree with all vendor-panel element ids, the Items/Buying/Selling
|
|
tab discovery) and `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
|
|
(§A has the byte-verified 0x005F Buy payload; §B.1 has `BuySingleItem` and
|
|
the Add-to-List staging pointer this document expands). Neither document is
|
|
re-derived here — findings are cited forward.
|
|
|
|
---
|
|
|
|
## Q1 — bought items land LAST; retail puts them FIRST
|
|
|
|
**Short answer: the insert-position rule is 100% server-sourced, ACE defaults
|
|
new inventory items to position 0 (front), and acdream already has a
|
|
byte-faithful port of the exact retail positional-insert algorithm, already
|
|
wired to the same wire field. On paper this already produces "bought items
|
|
land first." If the live symptom persists, the mechanism itself is not the
|
|
likely suspect — see the narrow open question at the end of this section.**
|
|
|
|
### Retail's insert-position mechanism
|
|
|
|
`ACCWeenieObject::ServerSaysContainID` (`pc:405992`, `0x0058be40`) is the
|
|
client-side handler for the `ContainID` UI-queue event (case `0x22` in
|
|
`UIQueueManager::ProcessNetBlobData`, `pc:359268-359293`):
|
|
|
|
```
|
|
void ACCWeenieObject::ServerSaysContainID(this, itemId, position, containerTypeFlag)
|
|
{
|
|
IDList* list = (containerTypeFlag == 0) ? &objInventory->_itemsList
|
|
: &objInventory->_containersList;
|
|
return IDList::AddAtNum(list, itemId, position, /*allowAppend*/ 1);
|
|
}
|
|
```
|
|
|
|
`IDList::AddAtNum` (`pc:443381`, `0x005add20`) is a genuine positional
|
|
doubly-linked-list insert: it walks to the node currently at index
|
|
`position` and splices the new node in BEFORE it (or appends if
|
|
`position == numIDs`). **The position is not computed locally — it is the
|
|
literal `arg3` the caller passed in, sourced from the wire.**
|
|
|
|
The caller (`UIQueueManager::ProcessNetBlobData` case `0x22`, `pc:359268-359291`)
|
|
reads four fields off the payload in order — item guid, container guid, a
|
|
third field (`var_1b8`, passed as the position), and a fourth field
|
|
(`var_1b0`, passed as the container-type flag) — then calls
|
|
`ACCWeenieObject::ServerSaysContainID(containerObj, itemId, var_1b8, var_1b0)`.
|
|
|
|
### ACE's wire writer confirms the field mapping and the default value
|
|
|
|
`GameEventItemServerSaysContainId` (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventItemServerSaysContainId.cs:7-14`):
|
|
|
|
```csharp
|
|
Writer.WriteGuid(itemToBeContained.Guid);
|
|
Writer.WriteGuid(container.Guid);
|
|
Writer.Write(itemToBeContained.PlacementPosition ?? 0); // ← the position field
|
|
Writer.Write((uint)itemToBeContained.ContainerType); // ← the container-type flag
|
|
```
|
|
|
|
This is byte-identical to the four fields `ServerSaysContainID` reads
|
|
(guid, container, position, type flag). **`PlacementPosition` on the wire is
|
|
literally retail's insert index.**
|
|
|
|
`Container.TryAddToInventory(WorldObject, out Container, int placementPosition = 0, ...)`
|
|
(`references/ACE/Source/ACE.Server/WorldObjects/Container.cs:499`) defaults
|
|
`placementPosition` to **0** and, when placing an item, shifts every
|
|
existing same-category item's `PlacementPosition` up by one
|
|
(`Container.cs:567-570`):
|
|
|
|
```csharp
|
|
worldObject.PlacementPosition = placementPosition;
|
|
containerItems.Where(i => !i.UseBackpackSlot && i.PlacementPosition >= placementPosition)
|
|
.ToList().ForEach(i => i.PlacementPosition++);
|
|
```
|
|
|
|
`Player.TryCreateInInventoryWithNetworking(WorldObject, out Container)`
|
|
(`Player_Inventory.cs:90-115`) — the method `FinalizeBuyTransaction` calls
|
|
for every purchased item (`Player_Commerce.cs:73-95`) — calls the **2-arg**
|
|
`TryAddToInventory(item, out container)` overload, which resolves to the
|
|
3-arg overload's default `placementPosition = 0`. There is only one matching
|
|
overload (`Container.cs:390` and `Container.cs:499`), so this is
|
|
unambiguous: **every ordinary item creation in ACE — buy, pickup, gem
|
|
identification, crafting output — places the new item at position 0,
|
|
pushing everything else back one slot.**
|
|
|
|
### acdream already ports this exact mechanism
|
|
|
|
`ClientObjectTable.InsertContainerMember` (`src/AcDream.Core/Items/ClientObjectTable.cs:1108-1142`)
|
|
is a direct, already-cited port of `IDList::AddAtNum`'s category-aware
|
|
positional insert:
|
|
|
|
```csharp
|
|
/// Port of retail ACCWeenieObject::AddContent @ 0x0058CCE0: items and
|
|
/// child containers have separate ordered IDLists and IDList::AddAtNum
|
|
/// clamps the requested index to the list length.
|
|
private void InsertContainerMember(ClientObject item, int requestedSlot)
|
|
```
|
|
|
|
It is reached from `ApplyServerMove` → `ApplyPlacement(..., retailContainerInsert: true)`
|
|
(`ClientObjectTable.cs:380-437`), which is called directly from the
|
|
`InventoryPutObjInContainer` (0x0022) wire handler
|
|
(`src/AcDream.Core.Net/GameEventWiring.cs:356-367`):
|
|
|
|
```csharp
|
|
registrar.Register(GameEventType.InventoryPutObjInContainer, e =>
|
|
{
|
|
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
|
if (p is null) return;
|
|
items.ApplyConfirmedServerMove(
|
|
p.Value.ItemGuid, p.Value.ContainerGuid,
|
|
newWielderId: 0u,
|
|
newSlot: (int)p.Value.Placement, // ← the SAME wire field ACE writes
|
|
containerTypeHint: p.Value.ContainerType);
|
|
});
|
|
```
|
|
|
|
`GameEvents.ParsePutObjInContainer` (`src/AcDream.Core.Net/Messages/GameEvents.cs:390-404`)
|
|
already documents the field layout with the exact ACE citation. And
|
|
`InventoryController.Populate()` (`src/AcDream.App/UI/Layout/InventoryController.cs:378-395`)
|
|
reads the pack's display order straight off
|
|
`ClientObjectTable.GetContents(open)` (`ClientObjectTable.cs:1269-1271`),
|
|
which returns the SAME `_containerIndex` list `InsertContainerMember`
|
|
maintains — there is no separate/secondary sort in the panel.
|
|
`InventoryController` also subscribes to `ObjectMoved`/`ContainerContentsReplaced`
|
|
(`InventoryController.cs:194-197`) and repaints on both, so a position
|
|
correction that lands after the item's own `CreateObject` is not stale in
|
|
the render.
|
|
|
|
**Net: every link in the chain — wire field → ACE default → acdream parser →
|
|
acdream positional insert → acdream panel read — already matches retail
|
|
insert-at-position-0.** This is not a one-line fix; there does not appear to
|
|
be a missing piece.
|
|
|
|
### Open question — if the symptom is still observed live
|
|
|
|
Everything above is verified from source, not from a live trace (this was a
|
|
read-only research pass). The one path this document did NOT rule out:
|
|
**stack-merge.** If ACE decides a purchased stackable item can merge into an
|
|
*existing* pack stack of the same WCID rather than creating a new item
|
|
(some games do this before falling back to `TryCreateInInventoryWithNetworking`),
|
|
the result would be a `GameMessageSetStackSize` on the existing item with NO
|
|
`ContainId`/position change at all — the item would stay wherever it already
|
|
was, appearing to "not move," which a user could report as "landed at the
|
|
end" if the existing stack happened to be at the end of the pack. This
|
|
document did not trace `ItemProfileToWorldObjects`/`Vendor.BuyItems_ValidateTransaction`
|
|
far enough to rule this in or out for every item category. **Recommendation:**
|
|
before writing any code, do a single live buy of a fresh (never-before-owned)
|
|
item and confirm placement with `ACDREAM_DUMP_CELLS`-style instrumentation or
|
|
a breakpoint on `ApplyConfirmedServerMove`, rather than re-deriving the
|
|
already-correct positional-insert logic above.
|
|
|
|
---
|
|
|
|
## Q2 — the vendor opens only at very close range
|
|
|
|
**Short answer: neither retail's client nor acdream's client gates the Use
|
|
SEND on distance — both send it unconditionally. The actual "walk to the
|
|
vendor" mechanic is entirely SERVER-driven in retail (ACE's `CreateMoveToChain`),
|
|
delivered back to the mover's own client as an ordinary broadcast motion
|
|
command, not as local client prediction. acdream has a complete, already-built
|
|
client-predicted move-to-target mechanism (`PlayerInteractionMovementSink.BeginApproach`),
|
|
but today it is wired ONLY to pickup, never to Use/Activate.**
|
|
|
|
### (a) Neither client gates Use by distance
|
|
|
|
`ItemHolder::UseObject` (`pc:402923`, `0x00588a80`) is the client function
|
|
every use-item entry point calls (`ClientUISystem::UseObject`,
|
|
`ItemHolder::UseObject` at the SmartBox/selection sites, the toolbar Use
|
|
button). Reading it in full: it does a 0.2s spam-throttle check
|
|
(`m_timeLastUsed`), a busy-request check
|
|
(`ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest`), and a series of
|
|
use-legality checks (trade-locked, wield-required, PK-altar confirmation) —
|
|
**there is no distance/range check anywhere in this function.** On the
|
|
success path it calls `CM_Inventory::Event_UseEvent(arg1)` unconditionally
|
|
(`pc:403043`) and shows the status text `"Approaching %s"` when the target's
|
|
`InqType() & 0x10` bit is set (`pc:403047-403051`) — that string is passive
|
|
UI feedback reacting to the send, not a gate on it.
|
|
|
|
acdream's equivalent send path, `SelectionInteractionController.RequestUse`
|
|
(`src/AcDream.App/Interaction/SelectionInteractionController.cs:217-235`),
|
|
matches this exactly: it calls `CancelPendingApproach()` then dispatches
|
|
`_transactions.TryDispatchUse(...)` immediately — **no `TryGetApproach`/range
|
|
check precedes it**, unlike the sibling `RequestPickup` method in the same
|
|
file (below). Both clients send Use unconditionally regardless of distance.
|
|
|
|
### (b) ACE's server-side range enforcement and move-to
|
|
|
|
`WorldObject.IsWithinUseRadiusOf` (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Use.cs:47-55`):
|
|
|
|
```csharp
|
|
public bool IsWithinUseRadiusOf(WorldObject wo, float? useRadius = null)
|
|
{
|
|
if (useRadius == null) useRadius = wo.UseRadius ?? 0.6f;
|
|
var cylDist = GetCylinderDistance(wo);
|
|
return cylDist <= useRadius;
|
|
}
|
|
```
|
|
|
|
`0.6f` is the fallback ONLY for objects with no authored `UseRadius`. A
|
|
vendor NPC's actual `UseRadius` is whatever its weenie's `PropertyFloat.UseRadius`
|
|
is authored to (typically several meters for an NPC, not the 0.6f ground-item
|
|
fallback) — the "very close range" symptom is not explained by this fallback
|
|
alone.
|
|
|
|
**The actual mechanism ("walk to it") lives in `Player.HandleActionUseItem`**
|
|
(`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`):
|
|
|
|
```csharp
|
|
if (item.CurrentLandblock != null && !item.Visibility && item.Guid != LastOpenedContainerId)
|
|
{
|
|
if (IsBusy) { SendUseDoneEvent(WeenieError.YoureTooBusy); return; }
|
|
CreateMoveToChain(item, (success) => TryUseItem(item, success));
|
|
}
|
|
else
|
|
TryUseItem(item);
|
|
```
|
|
|
|
`CreateMoveToChain` (`Player_Move.cs:37-65`) checks `CurrentLandblock.WithinUseRadius`
|
|
first; if already in range it just rotates the player toward the target and
|
|
fires the callback. **If NOT in range, it physically walks the player there**
|
|
via the server's own `MoveToManager`/physics — this is a real, gradual,
|
|
pathed walk broadcast to every observer (including the mover's own client)
|
|
as ordinary motion, not a teleport.
|
|
|
|
`Vendor.ActOnUse`'s own doc comment makes the contract explicit
|
|
(`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:223-228`):
|
|
|
|
> "This is raised by `Player.HandleActionUseItem`. **If the item was outside
|
|
> of range, the player will have been commanded to move using DoMoveTo
|
|
> before `ActOnUse` is called. When this is called, it should be assumed
|
|
> that the player is within range.**"
|
|
|
|
**Conclusion: ACE unconditionally walks the player to a distant vendor
|
|
before opening it — there is no server-side range REJECTION for a normal
|
|
Use, only a walk-then-open.**
|
|
|
|
### (c) Retail's client has no LOCAL prediction of this walk; acdream has one, but not wired to Use
|
|
|
|
Tracing how the server's move-to becomes visible: the `MoveToObject` motion
|
|
command a `CreateMoveToChain` walk produces is unpacked on the RECEIVE side
|
|
by `MovementManager::HandleNetMotion`-family code (`pc:300628-300647`, case
|
|
`6` of the `UIQueueManager` motion-command switch) via
|
|
`MovementParameters::UnPackNet(¶ms, MoveToObject, ...)` →
|
|
`CPhysicsObj::MoveToObject(...)` — this is the SAME wire-driven receive path
|
|
used for ANY entity's broadcast motion (NPCs, other players). Retail's
|
|
client does not pre-emptively simulate the walk from the `ItemHolder::UseObject`
|
|
call site itself (confirmed above — no local movement issued there); it only
|
|
starts visibly walking once the server's motion broadcast arrives, exactly
|
|
like watching any other entity walk.
|
|
|
|
acdream's `PlayerInteractionMovementSink.BeginApproach`
|
|
(`src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs:24-70`) is a
|
|
**client-predicted** move-to-target primitive — its own doc comment says so
|
|
verbatim: *"Installs retail's client-side TurnToObject/MoveToObject
|
|
prediction through the same MovementManager used by authoritative movement
|
|
packets."* It builds a `MovementStruct` with
|
|
`Type = approach.IsCloseRange ? MovementType.TurnToObject : MovementType.MoveToObject`
|
|
and installs it on `PlayerMovementController.MoveTo` directly — this is a
|
|
REAL, already-working local walk animation.
|
|
|
|
**But this mechanism is wired ONLY to pickup.**
|
|
`SelectionInteractionController.RequestPickup` (lines ~300-372) calls
|
|
`_query.TryGetApproach(itemGuid, out approach)` then
|
|
`_movement.BeginApproach(approach, ...)` before dispatching the pickup wire
|
|
message. `RequestUse` (lines 217-235, quoted in (a) above) has no equivalent
|
|
call — Use is sent with zero client-side approach handling, relying entirely
|
|
on ACE's server-driven walk-and-broadcast to eventually move the player and
|
|
open the vendor.
|
|
|
|
### What this means for "opens only at very close range"
|
|
|
|
Two distinct, evidenced possibilities, presented in order of how directly
|
|
they're supported by what was read in this pass:
|
|
|
|
1. **Missing local prediction is a cosmetic gap, not a functional one.**
|
|
Since ACE's `CreateMoveToChain` is unconditional and server-authoritative,
|
|
a distant vendor Use SHOULD still eventually open once the server's walk
|
|
completes and broadcasts back — acdream's local player движение pipeline
|
|
would need to correctly apply that INCOMING broadcast motion to itself.
|
|
`RuntimeLiveEntitySessionController.OnMotionUpdated`
|
|
(`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:211-222`)
|
|
DOES explicitly special-case the local player's own guid
|
|
(`bool isLocal = update.Guid == _runtime.PlayerIdentity.ServerGuid;`), so
|
|
this is not a silent guid-filter drop — but this pass did not trace all
|
|
the way to the visual/physics rendering of that update to confirm the
|
|
walk is actually SEEN by the user. If it is not rendered (or is rendered
|
|
but janky/instant), the user's experience would be "nothing visibly
|
|
happens unless I'm already close" even though the server is doing the
|
|
right thing.
|
|
2. **Porting the same client-predicted approach acdream already has for
|
|
pickup onto Use** (calling `_movement.BeginApproach` before
|
|
`RequestUse`'s dispatch, mirroring `RequestPickup`) would give Use the
|
|
SAME responsive, immediately-visible walk pickup already has, matching
|
|
the retail FEEL even though — per (c) above — it is technically MORE
|
|
client-prediction than retail's own client does for Use specifically.
|
|
This would need to be flagged as a deliberate acdream enhancement over
|
|
the byte-literal retail mechanism (per the project's "flag the tradeoff"
|
|
rule for redesigns), not silently added — but it directly and
|
|
unambiguously fixes the reported symptom regardless of which hypothesis
|
|
in (1) is true, since it makes the walk client-visible immediately
|
|
instead of only after a server round-trip.
|
|
|
|
**Recommendation:** since this is a UX/movement-authority area with a
|
|
documented project history of reverted prediction campaigns (CLAUDE.md's
|
|
Modern Runtime section), do NOT silently redesign. The narrower, safer first
|
|
step is verifying hypothesis (1) live (does the character visibly walk at
|
|
all when Use is sent from range, however slowly) before deciding whether (2)
|
|
is warranted as a deliberate enhancement.
|
|
|
|
---
|
|
|
|
## Q3 — "Add to list" (buy staging) semantics
|
|
|
|
**Short answer: retail's Buy button is a real, self-contained, immediate
|
|
purchase (`BuySingleItem`, already ported in Slice 6) that does not need
|
|
staging to exist. Staging is a SEPARATE, purely-client-local mechanism (the
|
|
"Buying" tab, `VendorBuyUI`) that batches multiple picks into one `0x005F`
|
|
call. Every button on both the "Items" and "Buying" tabs is now fully traced
|
|
below with exact retail addresses.**
|
|
|
|
All four staging-affecting buttons are cases inside
|
|
`gmVendorUI::HandleButtonClicks` (`pc:203950-204184`, `0x004c50d0`), the
|
|
single dispatcher for every vendor-panel button click (id → case is a direct
|
|
switch on the authored element id from the D0 layout tree in the Slice 5
|
|
research doc §B.4).
|
|
|
|
### Add to List — `0x100000C3` ("Items" tab)
|
|
|
|
```
|
|
case 0x100000c3:
|
|
{
|
|
ACCWeenieObject* item = ClientObjMaintSystem::GetWeenieObject(selectedID);
|
|
if (item != 0)
|
|
{
|
|
if (item->pwd._stackSize <= 1)
|
|
VendorItemsUI::AddToBuyList(m_itemsUI, item, 1);
|
|
else
|
|
VendorItemsUI::AddToBuyList(m_itemsUI, item, GetObjectSplitSize(item));
|
|
}
|
|
break;
|
|
}
|
|
```
|
|
|
|
(`pc:203970-203988`.) Reads the GLOBALLY-selected item, computes quantity
|
|
from the item's own stack size (1 for non-stackable) or the CURRENT toolbar
|
|
slider value (`ItemHolder::GetObjectSplitSize`, `pc:401465-401477`,
|
|
`0x00586F00`) for a stackable one, and calls
|
|
`VendorItemsUI::AddToBuyList(m_itemsUI, item, quantity)`. **This sends
|
|
NOTHING to the server** — it appends one entry (item + quantity) into
|
|
`gmVendorUI::m_buyList`, a `PackableList<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.
|