docs(plan): Slice 6b/6c vendor-completion contract — move-to-use, staging, selling, status-bar axiom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
33b45ee581
commit
ab3146ba88
2 changed files with 938 additions and 0 deletions
|
|
@ -688,3 +688,48 @@ a quantity; Buy → coins drop by the displayed price, the stack lands in
|
|||
the pack, the shop refreshes; a single-item buy works; insufficient funds
|
||||
fails cleanly; the session still closes on walk-away/portal with the
|
||||
materialized items removed.
|
||||
|
||||
## Slice 6b/6c — vendor completion (contract authored 2026-08-08)
|
||||
|
||||
**Research:** `docs/research/2026-08-08-slice6b-vendor-completion-research.md`.
|
||||
Closes the user's seven-finding gate batch (dropdown polish landed at
|
||||
`33b45ee5`). Ordered chunks, one implementer:
|
||||
|
||||
1. **Move-to-use (Q2):** wire the existing client-predicted approach
|
||||
primitive (`PlayerInteractionMovementSink.BeginApproach`,
|
||||
`MovementType.MoveToObject` — today Pickup-only) onto `RequestUse`, so
|
||||
using a vendor (or anything) beyond range walks the player in first,
|
||||
retail's shape. No new movement machinery.
|
||||
2. **Buy staging (Q3):** `AddToBuyList` semantics per the research
|
||||
trace — Add stages the selection + slider quantity into the Buying
|
||||
tab's list (rendered count/price), Buy on that tab sends ONE batched
|
||||
0x005F with every staged entry, the two removal shapes + Clear, and
|
||||
retail's X-close confirm dialog when staging is non-empty (the current
|
||||
unconditional hide stays correct only for empty staging).
|
||||
3. **Selling (Q4):** the Selling tab's list is THE drop target — accept
|
||||
pack-item drops via the `ExternalContainerController` drag-handler
|
||||
pattern, filter through `InqAcceptability` (all four rejection reasons
|
||||
with retail's exact strings), staged sell list, batched 0x0060, and
|
||||
the existing reconciliation machinery.
|
||||
4. **Status bar (Q5) — user evidence is the axiom:** the code-reading
|
||||
says our chain already matches retail, but the user's live session
|
||||
says the stack count/value/split-bar presentation is absent for a
|
||||
vendor selection. Reproduce in a UI-level test FIRST (drive the real
|
||||
SelectedObjectController mount with a vendor selection); fix what the
|
||||
reproduction reveals; if it genuinely cannot reproduce, STOP and
|
||||
report with the test as evidence for a live-probe session.
|
||||
5. **Pack order (Q1):** code-verified correct (wire placement position →
|
||||
front insert). NO change; the gate re-checks it live and #352 gets
|
||||
filed only if it reproduces.
|
||||
|
||||
Register: same-commit rows for any deviation; AP-161 narrows again as
|
||||
staging/sell land (its remaining scope should shrink to nothing or to
|
||||
precisely what stays absent).
|
||||
|
||||
**Gate (user):** click a vendor from afar → walk-in + open; stage two
|
||||
different items with quantities → Buy All → one transaction, coins/items
|
||||
correct; drag a sellable item onto the Selling tab → stages → Sell →
|
||||
coins up, item gone; an InqAcceptability-rejected item shows retail's
|
||||
refusal; X with a staged list → confirm dialog; stacked selection shows
|
||||
count/value/split-bar in the toolbar; bought items land at the front of
|
||||
the pack.
|
||||
|
|
|
|||
893
docs/research/2026-08-08-slice6b-vendor-completion-research.md
Normal file
893
docs/research/2026-08-08-slice6b-vendor-completion-research.md
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
# Slice 6b/6c vendor-completion research — staging, selling, and three live-gate gaps
|
||||
|
||||
**Date:** 2026-08-08
|
||||
**Trigger:** the user's live connected gate on the Slice 6 buy arc (`97cf8738`,
|
||||
`3c9fc57a`, `5224e438`) surfaced three residual mechanism gaps (bought items
|
||||
land last, vendor use-range feels too tight, the stacked-item status bar is
|
||||
missing pieces) plus the two fenced-out features (Buying-tab staging,
|
||||
Selling-tab drag-to-sell). This document answers all three gaps and both
|
||||
staging mechanisms with retail citations, so the implementer can pick up
|
||||
Slice 6b (staging) and 6c (selling) without re-deriving the decomp.
|
||||
|
||||
**Verified starting point:** repo HEAD at research time was `5224e438`
|
||||
("fix(vendor): gate-findings pass — the X button HIDES like retail, clicks
|
||||
return, the dropdown scrolls, pyreal suffix, staged-tab slots"). Read-only
|
||||
research; no code changed. `src/AcDream.App/UI/Layout/UiMenu.cs` and
|
||||
`VendorUiController.cs` are owned by a parallel implementer in this session —
|
||||
both were read in full for this document but are cited, not edited.
|
||||
|
||||
**Mandatory prior reading done first:**
|
||||
`docs/research/2026-08-08-slice5-vendor-browse-research.md` (browse lifecycle,
|
||||
the D0 layout tree with all vendor-panel element ids, the Items/Buying/Selling
|
||||
tab discovery) and `docs/research/2026-08-08-slice6-vendor-transactions-research.md`
|
||||
(§A has the byte-verified 0x005F Buy payload; §B.1 has `BuySingleItem` and
|
||||
the Add-to-List staging pointer this document expands). Neither document is
|
||||
re-derived here — findings are cited forward.
|
||||
|
||||
---
|
||||
|
||||
## Q1 — bought items land LAST; retail puts them FIRST
|
||||
|
||||
**Short answer: the insert-position rule is 100% server-sourced, ACE defaults
|
||||
new inventory items to position 0 (front), and acdream already has a
|
||||
byte-faithful port of the exact retail positional-insert algorithm, already
|
||||
wired to the same wire field. On paper this already produces "bought items
|
||||
land first." If the live symptom persists, the mechanism itself is not the
|
||||
likely suspect — see the narrow open question at the end of this section.**
|
||||
|
||||
### Retail's insert-position mechanism
|
||||
|
||||
`ACCWeenieObject::ServerSaysContainID` (`pc:405992`, `0x0058be40`) is the
|
||||
client-side handler for the `ContainID` UI-queue event (case `0x22` in
|
||||
`UIQueueManager::ProcessNetBlobData`, `pc:359268-359293`):
|
||||
|
||||
```
|
||||
void ACCWeenieObject::ServerSaysContainID(this, itemId, position, containerTypeFlag)
|
||||
{
|
||||
IDList* list = (containerTypeFlag == 0) ? &objInventory->_itemsList
|
||||
: &objInventory->_containersList;
|
||||
return IDList::AddAtNum(list, itemId, position, /*allowAppend*/ 1);
|
||||
}
|
||||
```
|
||||
|
||||
`IDList::AddAtNum` (`pc:443381`, `0x005add20`) is a genuine positional
|
||||
doubly-linked-list insert: it walks to the node currently at index
|
||||
`position` and splices the new node in BEFORE it (or appends if
|
||||
`position == numIDs`). **The position is not computed locally — it is the
|
||||
literal `arg3` the caller passed in, sourced from the wire.**
|
||||
|
||||
The caller (`UIQueueManager::ProcessNetBlobData` case `0x22`, `pc:359268-359291`)
|
||||
reads four fields off the payload in order — item guid, container guid, a
|
||||
third field (`var_1b8`, passed as the position), and a fourth field
|
||||
(`var_1b0`, passed as the container-type flag) — then calls
|
||||
`ACCWeenieObject::ServerSaysContainID(containerObj, itemId, var_1b8, var_1b0)`.
|
||||
|
||||
### ACE's wire writer confirms the field mapping and the default value
|
||||
|
||||
`GameEventItemServerSaysContainId` (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventItemServerSaysContainId.cs:7-14`):
|
||||
|
||||
```csharp
|
||||
Writer.WriteGuid(itemToBeContained.Guid);
|
||||
Writer.WriteGuid(container.Guid);
|
||||
Writer.Write(itemToBeContained.PlacementPosition ?? 0); // ← the position field
|
||||
Writer.Write((uint)itemToBeContained.ContainerType); // ← the container-type flag
|
||||
```
|
||||
|
||||
This is byte-identical to the four fields `ServerSaysContainID` reads
|
||||
(guid, container, position, type flag). **`PlacementPosition` on the wire is
|
||||
literally retail's insert index.**
|
||||
|
||||
`Container.TryAddToInventory(WorldObject, out Container, int placementPosition = 0, ...)`
|
||||
(`references/ACE/Source/ACE.Server/WorldObjects/Container.cs:499`) defaults
|
||||
`placementPosition` to **0** and, when placing an item, shifts every
|
||||
existing same-category item's `PlacementPosition` up by one
|
||||
(`Container.cs:567-570`):
|
||||
|
||||
```csharp
|
||||
worldObject.PlacementPosition = placementPosition;
|
||||
containerItems.Where(i => !i.UseBackpackSlot && i.PlacementPosition >= placementPosition)
|
||||
.ToList().ForEach(i => i.PlacementPosition++);
|
||||
```
|
||||
|
||||
`Player.TryCreateInInventoryWithNetworking(WorldObject, out Container)`
|
||||
(`Player_Inventory.cs:90-115`) — the method `FinalizeBuyTransaction` calls
|
||||
for every purchased item (`Player_Commerce.cs:73-95`) — calls the **2-arg**
|
||||
`TryAddToInventory(item, out container)` overload, which resolves to the
|
||||
3-arg overload's default `placementPosition = 0`. There is only one matching
|
||||
overload (`Container.cs:390` and `Container.cs:499`), so this is
|
||||
unambiguous: **every ordinary item creation in ACE — buy, pickup, gem
|
||||
identification, crafting output — places the new item at position 0,
|
||||
pushing everything else back one slot.**
|
||||
|
||||
### acdream already ports this exact mechanism
|
||||
|
||||
`ClientObjectTable.InsertContainerMember` (`src/AcDream.Core/Items/ClientObjectTable.cs:1108-1142`)
|
||||
is a direct, already-cited port of `IDList::AddAtNum`'s category-aware
|
||||
positional insert:
|
||||
|
||||
```csharp
|
||||
/// Port of retail ACCWeenieObject::AddContent @ 0x0058CCE0: items and
|
||||
/// child containers have separate ordered IDLists and IDList::AddAtNum
|
||||
/// clamps the requested index to the list length.
|
||||
private void InsertContainerMember(ClientObject item, int requestedSlot)
|
||||
```
|
||||
|
||||
It is reached from `ApplyServerMove` → `ApplyPlacement(..., retailContainerInsert: true)`
|
||||
(`ClientObjectTable.cs:380-437`), which is called directly from the
|
||||
`InventoryPutObjInContainer` (0x0022) wire handler
|
||||
(`src/AcDream.Core.Net/GameEventWiring.cs:356-367`):
|
||||
|
||||
```csharp
|
||||
registrar.Register(GameEventType.InventoryPutObjInContainer, e =>
|
||||
{
|
||||
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
items.ApplyConfirmedServerMove(
|
||||
p.Value.ItemGuid, p.Value.ContainerGuid,
|
||||
newWielderId: 0u,
|
||||
newSlot: (int)p.Value.Placement, // ← the SAME wire field ACE writes
|
||||
containerTypeHint: p.Value.ContainerType);
|
||||
});
|
||||
```
|
||||
|
||||
`GameEvents.ParsePutObjInContainer` (`src/AcDream.Core.Net/Messages/GameEvents.cs:390-404`)
|
||||
already documents the field layout with the exact ACE citation. And
|
||||
`InventoryController.Populate()` (`src/AcDream.App/UI/Layout/InventoryController.cs:378-395`)
|
||||
reads the pack's display order straight off
|
||||
`ClientObjectTable.GetContents(open)` (`ClientObjectTable.cs:1269-1271`),
|
||||
which returns the SAME `_containerIndex` list `InsertContainerMember`
|
||||
maintains — there is no separate/secondary sort in the panel.
|
||||
`InventoryController` also subscribes to `ObjectMoved`/`ContainerContentsReplaced`
|
||||
(`InventoryController.cs:194-197`) and repaints on both, so a position
|
||||
correction that lands after the item's own `CreateObject` is not stale in
|
||||
the render.
|
||||
|
||||
**Net: every link in the chain — wire field → ACE default → acdream parser →
|
||||
acdream positional insert → acdream panel read — already matches retail
|
||||
insert-at-position-0.** This is not a one-line fix; there does not appear to
|
||||
be a missing piece.
|
||||
|
||||
### Open question — if the symptom is still observed live
|
||||
|
||||
Everything above is verified from source, not from a live trace (this was a
|
||||
read-only research pass). The one path this document did NOT rule out:
|
||||
**stack-merge.** If ACE decides a purchased stackable item can merge into an
|
||||
*existing* pack stack of the same WCID rather than creating a new item
|
||||
(some games do this before falling back to `TryCreateInInventoryWithNetworking`),
|
||||
the result would be a `GameMessageSetStackSize` on the existing item with NO
|
||||
`ContainId`/position change at all — the item would stay wherever it already
|
||||
was, appearing to "not move," which a user could report as "landed at the
|
||||
end" if the existing stack happened to be at the end of the pack. This
|
||||
document did not trace `ItemProfileToWorldObjects`/`Vendor.BuyItems_ValidateTransaction`
|
||||
far enough to rule this in or out for every item category. **Recommendation:**
|
||||
before writing any code, do a single live buy of a fresh (never-before-owned)
|
||||
item and confirm placement with `ACDREAM_DUMP_CELLS`-style instrumentation or
|
||||
a breakpoint on `ApplyConfirmedServerMove`, rather than re-deriving the
|
||||
already-correct positional-insert logic above.
|
||||
|
||||
---
|
||||
|
||||
## Q2 — the vendor opens only at very close range
|
||||
|
||||
**Short answer: neither retail's client nor acdream's client gates the Use
|
||||
SEND on distance — both send it unconditionally. The actual "walk to the
|
||||
vendor" mechanic is entirely SERVER-driven in retail (ACE's `CreateMoveToChain`),
|
||||
delivered back to the mover's own client as an ordinary broadcast motion
|
||||
command, not as local client prediction. acdream has a complete, already-built
|
||||
client-predicted move-to-target mechanism (`PlayerInteractionMovementSink.BeginApproach`),
|
||||
but today it is wired ONLY to pickup, never to Use/Activate.**
|
||||
|
||||
### (a) Neither client gates Use by distance
|
||||
|
||||
`ItemHolder::UseObject` (`pc:402923`, `0x00588a80`) is the client function
|
||||
every use-item entry point calls (`ClientUISystem::UseObject`,
|
||||
`ItemHolder::UseObject` at the SmartBox/selection sites, the toolbar Use
|
||||
button). Reading it in full: it does a 0.2s spam-throttle check
|
||||
(`m_timeLastUsed`), a busy-request check
|
||||
(`ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest`), and a series of
|
||||
use-legality checks (trade-locked, wield-required, PK-altar confirmation) —
|
||||
**there is no distance/range check anywhere in this function.** On the
|
||||
success path it calls `CM_Inventory::Event_UseEvent(arg1)` unconditionally
|
||||
(`pc:403043`) and shows the status text `"Approaching %s"` when the target's
|
||||
`InqType() & 0x10` bit is set (`pc:403047-403051`) — that string is passive
|
||||
UI feedback reacting to the send, not a gate on it.
|
||||
|
||||
acdream's equivalent send path, `SelectionInteractionController.RequestUse`
|
||||
(`src/AcDream.App/Interaction/SelectionInteractionController.cs:217-235`),
|
||||
matches this exactly: it calls `CancelPendingApproach()` then dispatches
|
||||
`_transactions.TryDispatchUse(...)` immediately — **no `TryGetApproach`/range
|
||||
check precedes it**, unlike the sibling `RequestPickup` method in the same
|
||||
file (below). Both clients send Use unconditionally regardless of distance.
|
||||
|
||||
### (b) ACE's server-side range enforcement and move-to
|
||||
|
||||
`WorldObject.IsWithinUseRadiusOf` (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Use.cs:47-55`):
|
||||
|
||||
```csharp
|
||||
public bool IsWithinUseRadiusOf(WorldObject wo, float? useRadius = null)
|
||||
{
|
||||
if (useRadius == null) useRadius = wo.UseRadius ?? 0.6f;
|
||||
var cylDist = GetCylinderDistance(wo);
|
||||
return cylDist <= useRadius;
|
||||
}
|
||||
```
|
||||
|
||||
`0.6f` is the fallback ONLY for objects with no authored `UseRadius`. A
|
||||
vendor NPC's actual `UseRadius` is whatever its weenie's `PropertyFloat.UseRadius`
|
||||
is authored to (typically several meters for an NPC, not the 0.6f ground-item
|
||||
fallback) — the "very close range" symptom is not explained by this fallback
|
||||
alone.
|
||||
|
||||
**The actual mechanism ("walk to it") lives in `Player.HandleActionUseItem`**
|
||||
(`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`):
|
||||
|
||||
```csharp
|
||||
if (item.CurrentLandblock != null && !item.Visibility && item.Guid != LastOpenedContainerId)
|
||||
{
|
||||
if (IsBusy) { SendUseDoneEvent(WeenieError.YoureTooBusy); return; }
|
||||
CreateMoveToChain(item, (success) => TryUseItem(item, success));
|
||||
}
|
||||
else
|
||||
TryUseItem(item);
|
||||
```
|
||||
|
||||
`CreateMoveToChain` (`Player_Move.cs:37-65`) checks `CurrentLandblock.WithinUseRadius`
|
||||
first; if already in range it just rotates the player toward the target and
|
||||
fires the callback. **If NOT in range, it physically walks the player there**
|
||||
via the server's own `MoveToManager`/physics — this is a real, gradual,
|
||||
pathed walk broadcast to every observer (including the mover's own client)
|
||||
as ordinary motion, not a teleport.
|
||||
|
||||
`Vendor.ActOnUse`'s own doc comment makes the contract explicit
|
||||
(`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:223-228`):
|
||||
|
||||
> "This is raised by `Player.HandleActionUseItem`. **If the item was outside
|
||||
> of range, the player will have been commanded to move using DoMoveTo
|
||||
> before `ActOnUse` is called. When this is called, it should be assumed
|
||||
> that the player is within range.**"
|
||||
|
||||
**Conclusion: ACE unconditionally walks the player to a distant vendor
|
||||
before opening it — there is no server-side range REJECTION for a normal
|
||||
Use, only a walk-then-open.**
|
||||
|
||||
### (c) Retail's client has no LOCAL prediction of this walk; acdream has one, but not wired to Use
|
||||
|
||||
Tracing how the server's move-to becomes visible: the `MoveToObject` motion
|
||||
command a `CreateMoveToChain` walk produces is unpacked on the RECEIVE side
|
||||
by `MovementManager::HandleNetMotion`-family code (`pc:300628-300647`, case
|
||||
`6` of the `UIQueueManager` motion-command switch) via
|
||||
`MovementParameters::UnPackNet(¶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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue