feat: secure trade with other players - wire, RuntimeTradeState, the

authored gmSecureTradeUI window, and both retail open paths

Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).

- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
  senders byte-checked against ACE's readers; the ACE-discarded
  AcceptTrade echo carries zero-count item lists - AD-94), corrected +
  completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
  missed the SIDE dword, TradeFailure missed the reason), delegate-hole
  registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
  allegiance shape): session-scoped, clears at generation reset (new
  stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
  shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
  caught the mapping exactly as designed), combined ownership ledger,
  event routing with ACE's wrong-initiator RegisterTrade landmine
  honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
  0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
  partner name/status/count/grid, the authored 'Trade' accept toggle
  (accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
  surfaced honestly), the X close, drop-on-your-grid staging, per-mode
  accept cues (partner icon's authored Highlight state + Trade button
  Selected latch). Mounted via the vendor recipe (nine-slice chrome,
  hidden until RegisterTrade). ItemInteractionController's two policy
  arms now raise SecureTradeRequested instead of the stub toast; the
  drag path queues the dragged item until the window registers
  (ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).

Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).

Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 11:49:13 +02:00
parent bee38b0746
commit 067cbea8a5
26 changed files with 2682 additions and 42 deletions

View file

@ -0,0 +1,364 @@
# Retail secure-trade UI (`gmSecureTradeUI`) — decomp decode
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
build, PDB-named). All addresses are `acclient.exe` v11.4186 file offsets
(module base ~`0x00400000`). The class is **`gmSecureTradeUI`**, not
`gmTradeUI` — grep the correct name if re-deriving.
Element-type-id convention observed in this file (per `DynamicCast`/
`GetUIElementType` pairs): `1`=Button, `3`(alias `0xa`? see ItemList note
below)=Field-ish base, `0xc`=Text, and high `0x100000XX` values are
per-class dynamic type ids assigned to more complex controls
(`gmSecureTradeUI` itself is `0x10000012`, `UIElement_ItemList` is
`0x10000031` (also answers to legacy id `5`), `UIElement_UIItem` (an
item icon inside a list/paperdoll) is `0x10000032`).
## 1. Class shape: Create / Register / PostInit / ListenToElementMessage
| Method | Address | Notes |
|---|---|---|
| `gmSecureTradeUI::gmSecureTradeUI` (ctor) | `0x004c97c0` | Base-inits as `UIElement_Field`, then wires in `ObjectRangeHandler` and `ItemListDragHandler` interface vtables (multiple-inheritance vtable-slot assignment — the pseudo-C shows two of these assignments mislabeled as `&gmUrgentAssistanceUI::\`vftable'` / plain vtable-thunk addresses; **this is a known Binary-Ninja mislabeling artifact, not a real base-class relationship** — trust the interface list (`ObjectRangeHandler`, `ItemListDragHandler`), not the printed symbol name). Zeroes the button/list pointer block (`memset(&m_pTradeButton, 0, 0x28)`), inits `splitItemStackSize=0`, `splitItemClassID=INVALID_DID`. |
| `gmSecureTradeUI::Create` | `0x004c9890` | `operator new(0x634)` (0x634-byte instance) → ctor. |
| `gmSecureTradeUI::Register` | `0x004c9cc0` | `UIElement::RegisterElementClass(0x10000012, gmSecureTradeUI::Create)`. **Element class id = `0x10000012`.** |
| `gmSecureTradeUI::DynamicCast` | `0x004c96d0` | Answers `0x10000012` (self) or `3`. |
| `gmSecureTradeUI::GetUIElementType` | `0x004c96f0` | Returns `0x10000012`. |
| `gmSecureTradeUI::PostInit` | `0x004ca160` | See binding table below. |
| `gmSecureTradeUI::ListenToElementMessage` | `0x004cae80` | See click table below. |
| `gmSecureTradeUI::~gmSecureTradeUI` / scalar-deleting-dtor | `0x004c9650` / `0x004c9870` | Nothing trade-specific. |
### PostInit element bindings (`GetChildRecursive` + `DynamicCast`)
All at `0x004ca160`. Order as they appear in code:
| Child element id | `DynamicCast` arg | Field | Meaning |
|---|---|---|---|
| `0x10000086` | `1` (Button) | `m_pTradeButton` | Combined Accept/Decline action button (see click table). |
| `0x10000085` | `0xc` (Text) | `m_pSelfPlayerName` | Your own name label. |
| `0x10000087` | `0xc` (Text) | `m_pSelfTotalItemsLabel` | "N items" label, your side. |
| `0x10000088` | `0x10000031` (ItemList) | `m_pSelfItemsList` | **Your item grid.** Also calls `UIElement_ItemList::RegisterItemListDragHandler(eax_22, &this->vtable)``gmSecureTradeUI` is its own drag handler. |
| `0x1000007f` | *(no cast — raw `UIElement*`)* | `m_pOtherTradeStatusIndicator` | Partner "has accepted" status icon/indicator. |
| `0x1000007e` | `0xc` (Text) | `m_pOtherPlayerName` | Partner's name label. |
| `0x10000080` | `0xc` (Text) | `m_pOtherTotalItemsLabel` | "N items" label, partner side. |
| `0x10000081` | `0x10000031` (ItemList) | `m_pOtherItemsList` | **Partner's item grid** (no drag handler registered — you can't drag out of/into it). |
| `0x1000008a` | `1` (Button) | `m_pClearAllItemsButton` | "Clear all" / Reset button — same id is also read directly in `ListenToElementMessage` (see below), so it's bound to a field *and* hard-checked by id on click. |
PostInit ends by tail-calling `gmSecureTradeUI::Reset(this)` unconditionally
(both the found- and not-found-`0x1000008a` paths converge there).
Two more ids are referenced by id only (not bound to named fields in
PostInit):
| id | Where used | Meaning |
|---|---|---|
| `0x1000008b` | `ListenToElementMessage` | Click → `this->vtable->SetVisible(0)`. No server notify — this is the panel's own close/"X" button; it just hides the window. |
### `RegisterNoticeHandler` calls in `PostInit`
`PostInit` also does 13 `GlobalEventHandler::GetGlobalEventHandler()->
RegisterNoticeHandler(<id>, &this->vtable)` calls (lines ~`0x004ca17a`
`0x004ca2e5`). **The printed `<id>` values are decompiler noise** — most
show as raw hex (`0x4dd231``0x4dd23a`, `0x186a8`, `0x186a9`, `0x186ab`)
and one shows as a symbol (`gmKeyboardUI::ListenToElementMessage`) despite
being an unrelated class's method — confirmed by checking `0x4dd230`
directly: it *is* the start of `gmKeyboardUI::ListenToElementMessage`
(`0x004dd230`), i.e. Binary Ninja is printing "nearest known symbol" for a
raw 32-bit notice-type constant that happens to numerically fall inside
`.text`. **Do not port these literal values** — they are opaque
compile-time notice-type tags, not meaningful addresses. What's reliable
is the *count* (13, matching the 13 `RecvNotice_*`/`Recv...` handlers
below) and each handler's own address/behavior.
## 2. `RecvNotice_*` / notice handlers on `gmSecureTradeUI`
| Handler | Address | Behavior |
|---|---|---|
| `RecvNotice_RegisterTrade(iidInitiator?, iidPartner, arg4)` | `0x004ca5c0` | Calls `SetTradePartner(this, <partner id via a vtable call on `this-0x5f8`>)`, then `CPlayerSystem::RegisterObjectRangeHandler(playerSystem, &this->m_hashElementsRegisteredWith, arg3/*partner id*/, 0.0, 1, 0, 0.0, 0.0)`**registers an object-range handler on the trade partner**, so leaving visual range auto-closes the trade (see `OnObjectRangeExit` below). This is the "trade window opened" handler. |
| `RecvNotice_AddItemToTrade(itemId, side, slot)` | `0x004ca500` | `side==2``AddPartnerItem(itemId, slot)`; `side==1``AddMyItem(itemId, slot)`. |
| `RecvNotice_RemoveItemFromTrade(itemId, side)` | `0x004ca630` | `side==2``RemovePartnerItem`; `side==1``RemoveAddedItem`. |
| `RecvNotice_AcceptTrade(playerGuid)` | `0x004c9ce0` | `playerGuid==0`: reset-ish path — touches hash-registration bucket `[0]` (self status) then `UpdateTradeButtonState`. `playerGuid==local player`: bucket `[0]` (self) gets state `6`, then `UpdateTradeButtonState`. Otherwise (partner accepted): bucket `[4]` (== `m_pOtherTradeStatusIndicator`'s registration slot) gets state `6`. *(The "`ecx->m_hashKey->m_alphaImage(N)`" call syntax is another BN vtable-slot-mislabel — functionally this is "set the accept-status indicator element's state to N" via the notice-registration hash, most likely a plain `SetState` dispatch, not a literal alpha-image setter; treat the numeric state, not the printed method name, as ground truth.)* |
| `RecvNotice_DeclineTrade(playerGuid)` | `0x004c9d70` | Mirror of Accept: `playerGuid==local player` → bucket `[0]` state `1` + `UpdateTradeButtonState`; otherwise (partner declined) → bucket `[4]` (partner indicator) state `0xd`. |
| `RecvNotice_ClearTradeAcceptance()` | `0x004ca540` | Tailcalls `Reset()`. |
| `RecvNotice_CloseTrade(arg2)` | `0x004ca550` | `Reset()`, then if hash bucket `[5]` is bound, sets that element's text to the empty string (`PStringBase::s_NullBuffer`) — likely clears a status/announcement text field. |
| `RecvNotice_ResetTrade(arg2)` | `0x004ca670` | Tailcalls `Reset()`. |
| `RecvNotice_TradeFailure(itemId, arg3)` | `0x004ca680` | `RemoveAddedItem(itemId)` — rolls back an optimistically-added self item that the server rejected. |
| `RecvNotice_TradeAnItemForDummies(itemId)` | `0x004caf30` | Tailcalls `TradeAnItemForDummies(itemId)` (see below — this is the "double-click to add" convenience path, driven by a server/engine notice, not just UI). |
| `RecvNotice_ServerSaysAttemptFailed(arg2)` | `0x004c98c0` | `this->m_hashElementsRegisteredWith...m_aInplaceBuckets[9] = nullptr` — clears a registration slot directly (no method call); likely cancels an in-flight optimistic-add tracking entry. Low confidence on exact semantics — flagged, not guessed. |
| `RecvNotice_ServerSaysMoveItem(...)``ServerSaysMoveItem` | `0x004cad30``0x004cac20` | Full signature `(itemId, arg3, arg4, arg5, destContainerOrPlayerId, arg7, arg8, arg9)`. If `destContainerOrPlayerId == m_iidTradePartner` and `ClientTradeSystem::IsPartnerTradingItem(itemId)` and the item isn't already in `m_pOtherItemsList`: pulls the item off any pending destruction queue and calls `AddPartnerItem(itemId, ClientTradeSystem::GetItemLocationInPartnerTradeList(itemId))`. This is the "partner's item physically arrived via a container-move notice" path (items placed into the trade appear to move into a hidden container owned by the partner; this handler is what makes that show up in the partner grid). |
| `RecvNotice_ItemAttributesChanged(itemId, arg3)``ItemAttributesChanged` | `0x004cad40``0x004ca9d0` | Only acts if `this->splitItemID != 0` (i.e. mid-split-for-trade) and `arg3 & 1`. If the changed item matches the pending split's class id and its new stack size equals the expected split remainder size, calls `AddItem(itemId, 0, 0, 0, 1)` and clears `splitItemID`. This is the completion of the "split stack before trading" flow started in `AcceptDragObject`. |
| `OnObjectRangeExit(arg2)` | `0x004ca4c0` | If `arg2 == ClientTradeSystem::GetTradeSystem()->m_iidTradePartner`: `ClientTradeSystem::CloseTradeNegotiations()` + `Reset()`. This is the range-based auto-close wired up by `RecvNotice_RegisterTrade`'s `RegisterObjectRangeHandler` call. |
| `OnVisibilityChanged(arg2)` | `0x004ca470` | On becoming hidden (need to confirm polarity from `arg2`, not fully traced) calls `Reset()`. |
`Reset()` itself (`0x004ca100`): guarded by an internal bit-flag check
(`(this->__inner23 >> 0x11) & 1`, likely "is this element actually
constructed/active" — early-outs otherwise). Then: `SetTradePartner(0)`
(clears partner name), `m_pOtherTradeStatusIndicator->SetState(0xd)`
(neutral), `FlushTradeLists()`, `SetMyItemNumber()`,
`SetOtherItemNumber()`, `UpdateTradeButtonState()`.
`FlushTradeLists()` (`0x004c9ac0`): for every item currently in
`m_pSelfItemsList`, calls `ACCWeenieObject::SetTradeState(item, 0)`
(un-flags it as "in a trade") then `ItemList_Flush`. For every item in
`m_pOtherItemsList`, if it's not player-owned and not already flagged
container-location `0x3f00000`, queues it for destruction
(`AddContentsToDestructionQueue`) — the client-side proxy objects
representing the partner's offered items are throwaway and get destroyed
when the trade lists are cleared.
## 3. Opening the trade panel
### Use-on-player path (confirmed)
`CPlayerSystem::UsingItem(itemId, arg3, arg4)` at `0x00562f70` calls
`ItemHolder::DetermineUseResult(item)` (`0x00588460`) and switches on
`(result - 2)`. **`case 3` → result `5` → `ClientTradeSystem::
AttemptToOpenTradeNegotiations(GetTradeSystem(), itemId)`** at
`0x00563022`.
`ItemHolder::DetermineUseResult` returns `5` specifically at
`0x005885f7` when: the target is *not* player-owned, has no capacity/
component-pack shortcut, isn't a "negative InqType" special object,
isn't directly `ItemUses::IsUseable`, **and `esi->vtable->IsPlayer()`
is true and `esi->id != <local player id>`** — i.e., **"use" on any
other player, with no other higher-priority use-result, resolves to
"open trade."** This is the canonical "use on player → trade" trigger.
`ItemHolder::DetermineUseResult``0x00588460`.
`ClientTradeSystem::AttemptToOpenTradeNegotiations``0x0056dee0`:
refuses (shows "You need to be in peace mode to …") if
`ClientCombatSystem::GetCombatSystem()->combatMode != NONCOMBAT_COMBAT_MODE`.
Otherwise sends `CM_Trade::Event_OpenTradeNegotiations(targetPlayerId)`
(`0x0056df6a`) — the outbound wire request.
### Drag-item-on-player path (confirmed)
`ItemHolder::AttemptPlaceIn3D(arg1, arg2, arg3)` at `0x00588600` is the
generic "item dropped onto object X in the 3D view" dispatcher. At
`0x005887d0`:
```
if (PlayerModule::DragItemOnPlayerOpensSecureTrade(&playerModule) != 0
&& droppedOnObject->vtable->IsPlayer() != 0)
{
ClientTradeSystem::AttemptToTradeItem(GetTradeSystem(), targetPlayerId, droppedItemId);
return 0;
}
```
So the player-option gate is real and it's checked **before** the
`InqType()==0x10` (give-directly) and container/lock checks that follow
in the same function — dragging onto a player short-circuits straight to
trade-attempt when the option is on, before any "give" logic runs.
- Option getter/setter: `PlayerModule::DragItemOnPlayerOpensSecureTrade`
(`0x005d31b0`) / `PlayerModule::SetDragItemOnPlayerOpensSecureTrade`
(`0x005d31c0`). Player-option enum id: `DragItemOnPlayerOpensSecureTrade_PlayerOption`.
StringTable label/help keys: `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade`
/ `..._Help`, registered via `compute_str_hash` at `0x004a0f5f` /
`0x004a0f85`, added to the options page via
`PlayerOptionPage::AddToggleOption` at `0x004a0fa4`.
`ClientTradeSystem::AttemptToTradeItem(targetPlayerId, itemId)`
`0x0056df80`: requires the item be player-owned. If already trading with
someone:
- same partner → `CM_Trade::SendNotice_TradeAnItemForDummies(itemId)`
(adds the item directly to the already-open trade — the "convenience"
add-while-already-negotiating path).
- different partner → refuses with "You are already trading with
som…".
If not yet trading: calls `ItemHolder::UseObject(targetPlayerId, 0, 0)`
(i.e. re-enters the same **use** path as above, effectively "use the
target player") and stashes `attemptTradeToPlayerID` /
`attemptTradeObjectID` for later. Once
`ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` (`0x0056e050`, the
inbound "trade window opened" handler that also fires
`CM_Trade::SendNotice_RegisterTrade``gmSecureTradeUI::RecvNotice_RegisterTrade`
above) sees `m_iidTradePartner == attemptTradeToPlayerID`, it calls
`AttemptToTradeItem` again to actually queue the drag-dropped item into
the now-open trade.
### Internal UI-queue notice ids (bonus — confirms §1's `PostInit` id noise is noise)
`CM_Trade::DispatchUI_Recv_*` (the layer between the network/engine queue
and the `RecvNotice_*` UI calls) gate on small, contiguous internal
tag values — these are **not** the raw wire opcodes, but they cross-check
cleanly against each other and confirm the `PostInit` `RegisterNoticeHandler`
hex noise (§1) is unrelated decompiler artifact, not meaningful data:
| Notice | Internal tag | Dispatch fn addr |
|---|---|---|
| RegisterTrade | `0x1fd` | `0x006acf20` |
| OpenTrade | `0x1fe` | `0x006acef0` |
| CloseTrade | `0x1ff` | `0x006ace90` |
| AddToTrade | `0x200` | `0x006ace20` |
| RemoveFromTrade | `0x201` | `0x006acf80` |
| AcceptTrade | `0x202` | `0x006ace09`/`0x006acdf0` |
| DeclineTrade | `0x203` | `0x006ace60`(dispatch fn header `0x006acec0`) |
| ResetTrade | `0x205` | `0x006acfb0` |
| TradeFailure | `0x207` | `0x006acfe0` |
| ClearTradeAcceptance | `0x208` | `0x006ace60` |
## 4. Item grids
- **Your grid**: element id `0x10000088`, bound to `m_pSelfItemsList`,
type `UIElement_ItemList` (element-type-id `0x10000031`). This list
registers itself as an `ItemListDragHandler` target with `this`
(`gmSecureTradeUI`) as handler — items are dragged **in** here by the
player. Individual entries, when read back with
`UIElement_ListBox::GetItem`, are `DynamicCast(0x10000032)` — a
`UIElement_UIItem` icon wrapping an `ACCWeenieObject` (`weenObj` field).
- **Partner's grid**: element id `0x10000081`, bound to
`m_pOtherItemsList`, same `UIElement_ItemList` type, **no drag handler
registered** — populated only by network notices
(`AddPartnerItem`/`RemovePartnerItem`/`ServerSaysMoveItem`), not by
local drag-drop.
- **Counts**: `SetMyItemNumber()` (`0x004c98d0`) / `SetOtherItemNumber()`
(`0x004c9970`) build a `StringInfo` with
`SetStringIDandTableEnum(&info, <ID>, 0x10000001)` +
`AddVariable_Int(count)` and write the resolved string into
`m_pSelfTotalItemsLabel` / `m_pOtherTotalItemsLabel`. The printed `<ID>`
literal is `0`, which is almost certainly the decompiler showing the
static pre-initializer value of the global `ID_SecureTrade_TotalItemsLabel`
(see §5) rather than the true runtime-computed hash — same class of
artifact as the `PostInit` notice ids. Table-enum `0x10000001` is a
StringTable category constant, not further resolved here.
- **Accept/decline is presented via**:
1. `m_pTradeButton` (id `0x10000086`) — one physical button whose
current `m_state` decides what a click does (see §1 click table):
`m_state==6` → click triggers `AcceptTheTrade`; `m_state==1`
click triggers `DeclineTheTrade`. `UpdateTradeButtonState()`
(`0x004c9700`) disables the button (state `0xd`) whenever total
items across both sides is `0`, and re-enables (state `1`) once
items exist. **Caution**: `UIElement_Button::SetState` (`0x00471da0`)
shows this button uses a "latch" attribute pair (attrs `0xb`/`0xe`)
where `SetState(6)`/`SetState(1)` toggle a latch flag and
early-return *without* necessarily rewriting `m_state` to that
literal value unless the latch was already in the requested
position, in which case it falls through to the generic
disabled-state path which does write `m_state`. The exact
accept/decline toggle semantics are therefore genuinely convoluted
in the retail binary; recommend the port drive the button's visual
state from `ClientTradeSystem`'s own accepted/declined booleans
rather than replicating this latch dance literally, and validate
against a live retail trade if uncertain (this is exactly the kind
of "state interacts with prior state in ways not obvious from
reading" case the CLAUDE.md's cdb workflow exists for).
2. `m_pOtherTradeStatusIndicator` (id `0x1000007f`) — the partner-side
accept/decline indicator icon, driven directly by
`RecvNotice_AcceptTrade`/`RecvNotice_DeclineTrade` (state `6` =
accepted, `0xd` = neutral/declined) and reset to `0xd` on every
`AddMyItem`/`AddPartnerItem`/`RemoveAddedItem`/`RemovePartnerItem`
(any list change silently un-accepts the visual, matching retail's
"adding an item clears both sides' acceptance" rule).
3. Drag-affordance color feedback during hover uses the same generic
accept/reject drag-state pair used elsewhere in the UI:
`UIElement_UIItem::SetDragAcceptState(item, 0x10000040)` (acceptable
— green) / `0x10000041` (rejected — red), set from
`OnItemListDragOver` (`0x004ca980`) via `DragItemAcceptable`.
`DragItemAcceptable(itemId, quiet)` (`0x004ca6a0`): if the item is
player-owned and not already in the self list → acceptable. If not
player-owned: acceptable only path is absent (falls to `return 0`); if
`quiet==0` it also posts a rejection `StringInfo` via
`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` (the literal string text
itself is unresolved — printed as a mislabeled vtable-slot symbol,
another string-adjacent-to-vtable BN artifact, not a real symbol
reference).
`AddItem(itemId, slot, quietFlag, splitAllowedFlag, fromRecursion)`
(`0x004ca780`): if the item has no contained items/containers (i.e. not
itself a container), inserts it directly into `m_pSelfItemsList` and
calls `ClientTradeSystem::AddItemToSelfTradeList` (the outbound wire
call). If it *is* a container (has contained items) and `splitAllowedFlag`
is set, it instead announces "Trading contents of %s" and recursively
calls `AddItem` for every contained item — **dragging a container into
the trade grid trades its contents individually, not the container
itself.**
`AcceptDragObject(itemId)` (`0x004caa40`): if `DragItemAcceptable`
passes and the dropped item's current stack size already equals the max
split size, adds it directly. Otherwise attempts
`ItemHolder::AttemptToPlaceInContainer` to split off the correct amount
first (stashing `splitItemID`/`splitItemClassID`/`splitItemStackSize`,
announcing "Splitting the %s before trading …"); the actual add happens
later when `RecvNotice_ItemAttributesChanged`/`ItemAttributesChanged`
sees the split completion (§2). If the split attempt itself fails,
announces "Cannot split the stack to trade …".
`TradeAnItemForDummies(itemId)` (`0x004cad50`): the convenience
"just trade this stack, splitting-and-all" entry point invoked from the
`RecvNotice_TradeAnItemForDummies` notice (fired e.g. from
`AttemptToTradeItem`'s same-partner re-add case). Refuses with "You must
split the stack before …" if the item is the globally
`ACCWeenieObject::selectedID` and a split is already in flight
(`GenItemHolder::splitSize == maxSplitSize`); otherwise calls `AddItem`
directly.
`HandleDropRelease` (`0x004cae10`) / message id `0x15` in
`ListenToElementMessage`: only processes a drop if the drop's ancestor
chain lands inside `m_pSelfItemsList` (`UIElement::IsAncestorOfMe`) —
confirms drops are only ever accepted onto your own grid, never the
partner's.
## `ListenToElementMessage` click table (`0x004cae80`)
| `idMessage` | `idElement` | Action |
|---|---|---|
| `1` (click) | `0x10000086` (trade button) | `m_state==6``AcceptTheTrade()`; `m_state==1``DeclineTheTrade()`. |
| `1` (click) | `0x1000008a` (clear-all) | `ClientTradeSystem::ResetTrade(GetTradeSystem())` — outbound reset. |
| `1` (click) | `0x1000008b` (close/X) | `this->vtable->SetVisible(0)` — local-only hide, no wire traffic. |
| `0x15` (drop) | — | `HandleDropRelease` (self-grid-only, see §4). |
`AcceptTheTrade()` (`0x004c9a10`): before sending accept, verifies the
locally-displayed item counts (`GetNumUIItems` on both lists) match the
server-known counts (`ClientTradeSystem::GetNumSelfObjectsInTrade`/
`GetNumPartnerObjectsInTrade`); mismatch →
`ClientTradeSystem::NotifyServerThatTradeIsOutOfSync()` instead of
accepting — an explicit desync guard the port should replicate.
`DeclineTheTrade()` (`0x004c9a90`) is unconditional:
`ClientTradeSystem::DeclineTrade()`.
## 5. StringTable keys
Only one trade-panel-specific key was found via `compute_str_hash("ID_...")`
scan of the whole file:
| Key | Hash-init address |
|---|---|
| `ID_SecureTrade_TotalItemsLabel` | `0x006f2d8d` |
Related but not-panel-body keys (player options / chat, not the panel
itself): `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade` (+`_Help`) at
`0x004a0f5f`/`0x004a0f85`; `ID_PlayerOption_IgnoreTradeRequests` (+`_Help`)
at `0x004a0eee`/`0x004a0f14`; `ID_ChatOption_TextFilter_Trade` (+`_Desc`)
at `0x006f06cd`/`0x006f06ed`; `ID_Chat_ChatTargetMenuTrade` /
`ID_Chat_TellToTrade` at `0x006f39ad`/`0x006f3a6d`.
**No other `ID_SecureTrade*` or `ID_Trade*` keys exist in the pseudo-C.**
Player-name and other-side labels are populated directly from
`ACCWeenieObject::GetObjectNameWide` (not StringTable), and the button's
own caption/tooltip text is presumably baked into the LayoutDesc/DAT
authoring for element `0x10000086` rather than resolved at runtime here —
the porting engineer should pull the actual authored panel (element class
`0x10000012`, its children `0x1000007e``0x1000008b`) from the game's
LayoutDesc DAT resource via the existing `LayoutImporter`/UI-Studio
tooling to get real control names/captions/positions; this decomp pass
only recovers behavior, not layout.
## NOT FOUND
- The literal wire/network opcode for the trade `GameAction`/`GameEvent`
family (as opposed to the internal `0x1fd``0x208` UI-queue tags in
§3) was not located in `acclient.h` or the pseudo-C by direct grep;
cross-check `references/ACE/` server-side trade handlers if the exact
byte-level wire opcode is needed for a lane-B/network task.
- The exact text of the two `StringInfo`/`ECM_UI::SendNotice_DisplayStringInfo`
messages in `DragItemAcceptable` and one in `AcceptDragObject` that show
as mislabeled vtable-slot symbols instead of string literals (BN
string-adjacent-to-vtable artifact) — content unrecoverable from this
file alone.
- Full struct layout / field offsets for `gmSecureTradeUI` and
`ClientTradeSystem` are not present in `acclient.h` (not reconstructed
in this PDB pass); field names above are taken from the pseudo-C's own
`this->fieldName` labels, which the decompiler DID resolve correctly
(these are real PDB member names, unlike the notice-id/string-literal
artifacts flagged above).

View file

@ -0,0 +1,316 @@
# Secure-trade wire protocol (Lane B research)
2026-08-14. Sources: ACE (`references/ACE/Source/ACE.Server/`, authoritative
for what our local server accepts/sends), retail decomp
(`docs/research/named-retail/acclient_2013_pseudo_c.txt`, `CM_Trade` /
`ClientTradeSystem` / `Trade`), holtburger Rust client protocol
(`references/holtburger/crates/holtburger-protocol/src/messages/trade/`).
All three agree byte-for-byte on every field ACE actually implements; no
disagreements found. holtburger implements the FULL retail set (including
`OpenTrade`/`RemoveFromTrade`, which ACE never sends) — its structs are
cited as the independent cross-check.
Frame headers (`GameActionPacket.cs:9-17`, `GameEventMessage.cs:14-26`):
- C→S action frame: opcode `0xF7B1` (GameAction) → `[sequence u32][GameActionType u32][action-specific fields]`.
- S→C event frame: opcode `0xF7B0` (GameEvent) → `[PlayerGuid u32][GameEventSequence u32][GameEventType u32][event-specific fields]`.
All guids below are `u32` (`ObjectGuid`/`Guid`, little-endian).
## Table 1 — client→server actions (GameActionType)
| Opcode | Name | Payload fields (order, type) | ACE file:line | Retail sender (address) |
|---|---|---|---|---|
| `0x01F6` | OpenTradeNegotiations | `tradePartnerGuid: u32` | `GameActionOpenTradeNegotiations.cs:10` | `CM_Trade::Event_OpenTradeNegotiations` @ `0x0056d300` |
| `0x01F7` | CloseTradeNegotiations | (none) | `GameActionCloseTradeNegotiations.cs:8` | `CM_Trade::Event_CloseTradeNegotiations` @ `0x0056d1e0` |
| `0x01F8` | AddToTrade | `itemGuid: u32`, `tradeSlot: u32` | `GameActionAddToTrade.cs:9-10` | `CM_Trade::Event_AddToTrade` @ `0x0056d0d0` |
| `0x01F9` | *(unassigned — reserved for RemoveFromTrade)* | n/a | not in `GameActionType.cs` | retail has no `Event_RemoveFromTrade` C→S sender either — removal is client-local-only (see quirks) |
| `0x01FA` | AcceptTrade | `partnerGuid: u32`, `tradeStamp: f64`, `tradeStatus: u32`, `initiatorGuid: u32`, `initiatorAccepts: u32(bool)`, `partnerAccepts: u32(bool)`, *(then `self_list`/`partner_list`, variable-length `PackableList<ContentProfile>`, ACE never reads these — see quirks)* | `GameActionAcceptTrade.cs:11-16` | `CM_Trade::Event_AcceptTrade` @ `0x0056ad010`, packing `Trade::Pack` @ `0x005b9ff0` |
| `0x01FB` | DeclineTrade | (none) | `GameActionDeclineTrade.cs:8` | `CM_Trade::Event_DeclineTrade` @ `0x0056d270` |
| `0x0204` | ResetTrade | (none) | `GameActionResetTrade.cs:8` | `CM_Trade::Event_ResetTrade` @ `0x0056d3d0` |
The 6 fixed `AcceptTrade` fields are exactly `Trade::Pack`'s first 6 members
(`_partner, _stamp, _status, _initiator, _accepted, _p_accepted`
`acclient_2013_pseudo_c.txt:457619-457648`); holtburger's
`AcceptTradeActionData` (`holtburger-protocol/src/messages/trade/actions.rs:159-205`,
test fixture at `:265-280`) reproduces the identical 24-byte-after-guid
layout independently, confirming the read order.
## Table 2 — server→client events (GameEventType)
| Opcode | Name | Payload fields (order, type) | ACE file:line | Retail parser (address) |
|---|---|---|---|---|
| `0x01FD` | RegisterTrade | `initiator: u32(guid)`, `partner: u32(guid)`, `stamp: u64` (ACE always writes `0L`) | `GameEventRegisterTrade.cs:10-12` | `ClientTradeSystem::Handle_Trade__Recv_RegisterTrade(this, initiator, partner, double stamp)` @ `0x0056e050`, dispatched via `DispatchUI_Recv_RegisterTrade` @ `0x006acf20` (type check `== 0x1fd`) |
| `0x01FE` | OpenTrade | `partnerGuid: u32` | **not implemented by ACE — no C# class emits `GameEventType.OpenTrade`** | `ClientTradeSystem::Handle_Trade__Recv_OpenTrade` @ `0x0056d930`, dispatch @ `0x006acef0` (`== 0x1fe`) |
| `0x01FF` | CloseTrade | `endTradeReason: u32` (`EndTradeReason`: Normal=1, EnteredCombat=2, Canceled=0x51) | `GameEventCloseTrade.cs:10` | `Handle_Trade__Recv_CloseTrade` (calls `SendNotice_CloseTrade`), dispatch @ `0x006ace90` (`== 0x1ff`) |
| `0x0200` | AddToTrade | `objectGuid: u32`, `tradeSide: u32` (Self=1, Partner=2), `slot: u32` (ACE always writes `0`) | `GameEventAddToTrade.cs:10-12` | dispatch @ `0x006ace20` (`== 0x200`), reads 3 dwords at +4/+8/+0xc |
| `0x0201` | RemoveFromTrade | `objectGuid: u32`, `mode: u32` (1 = remove one, 2 = remove all matching qty — `Trade::RemoveItem`) | **not implemented by ACE — no C# class emits `GameEventType.RemoveFromTrade`** | `Handle_Trade__Recv_RemoveFromTrade` @ `0x0056dc00`, dispatch @ `0x006acf80` (`== 0x201`) |
| `0x0202` | AcceptTrade | `whoAccepted: u32(guid)` | `GameEventAcceptTrade.cs:10` | `Handle_Trade__Recv_AcceptTrade` @ `0x0056dc40`, dispatch @ `0x006acdf0` (`== 0x202`) — client compares `arg2` against its own `SmartBox::player_id` to know self-vs-partner |
| `0x0203` | DeclineTrade | `whoDeclined: u32(guid)` | `GameEventDeclineTrade.cs:10` | dispatch @ `0x006acec0` (`== 0x203`) |
| `0x0205` | ResetTrade | `whoReset: u32(guid)` | `GameEventResetTrade.cs:10` | `Handle_Trade__Recv_ResetTrade` (calls `Trade::Reset`), dispatch @ `0x006acfb0` (`== 0x205`) |
| `0x0207` | TradeFailure | `objectGuid: u32`, `reason: u32` (`WeenieError`) | `GameEventTradeFailure.cs:10-11` | `Handle_Trade__Recv_TradeFailure` @ `0x0056d990` (calls `Trade::RemoveItem(objectGuid, 1)` before UI notice), dispatch @ `0x006acfe0` (`== 0x207`) |
| `0x0208` | ClearTradeAcceptance | (none) | `GameEventClearTradeAcceptance.cs` (no extra fields) | dispatch @ `0x006ace60` (`== 0x208`) |
holtburger's `events.rs` independently reproduces every field above,
including the "always 0 in ACE" comments on `RegisterTradeEventData.unknown`
(`:29`) and `AddToTradeEventData.slot` (`:83`) — these comments were written
from observing ACE traffic, corroborating the ACE source read.
`SendNotice_*` retail functions (e.g. `SendNotice_AcceptTrade` @ `0x0056ad460`)
are **not** wire sends — they walk `gmGlobalEventHandler`'s registered UI
notice-handler list to update the local trade window after a `Recv_*`
dispatch. Do not confuse with `Event_*` (the only C→S wire builders, via
`Proto_UI::SendToWeenie`).
## Sequencing narrative
### Happy path: A initiates trade with B, both add items, both accept
1. **A → S**: `OpenTradeNegotiations(0x01F6)` targeting B's guid
(`Player_Trade.cs:30-100`).
2. Server-side checks in order (`HandleActionOpenTradeNegotiations`,
`initiator=true` branch, `:32-83`): A not Olthoi → B online → B not
Olthoi → B not `IgnoreAllTradeRequests` → neither already `IsTrading`
neither in combat mode → **A moves/rotates to B via `CreateMoveToChain`**
(this is the "if in range" distance gate — failure sends
`WeenieError.TradeMaxDistanceExceeded`, no trade starts).
3. On successful approach, **S → A**: `RegisterTrade(0x01FD)` with
`(initiator=B.Guid, partner=B.Guid, 0L)` — note ACE passes `tradePartner.Guid`
for BOTH fields here (`Player_Trade.cs:80`), not `(A.Guid, B.Guid)`; this
looks like an ACE bug relative to retail's `_partner`/`_initiator`
semantics, but it is what ships (flagged again in Quirks below).
4. Internally A calls `tradePartner.HandleActionOpenTradeNegotiations(A.Guid, initiator:false)`
(`:82`) — this is a same-process direct call into B's Player object, not
a wire message. B's non-initiator branch (`:85-99`) sets
`IsTrading=true` on both, clears both `ItemsInTradeWindow`, sets
`TradePartner` cross-links, then:
5. **S → B**: `RegisterTrade(0x01FD)` with `(initiator=B.Guid, partner=B.Guid, 0L)`
(`:98` — same "wrong" both-fields-partner value, since this runs as B's own
`Session`).
6. **A → S**: `AddToTrade(0x01F8)` with `(itemGuid, tradeSlot)` for each item A
drags into the window. Server (`HandleActionAddToTrade`, `:116-175`):
rejects if `TradeTransferInProgress`; clears both sides'
`TradeAccepted`; resolves the item from A's inventory or equipped slot;
refuses attuned/pet-bound items and uncarryable-uniques (see Quirks);
adds to `A.ItemsInTradeWindow`; **S → A**: `AddToTrade(0x0200)`
`(itemGuid, TradeSide.Self=1, 0)` immediately; then after a
0.001s `ActionChain` delay, **S → B**: `AddToTrade(0x0200)`
`(itemGuid, TradeSide.Partner=2, 0)`. Same flow mirrored when B adds
items (roles of Self/Partner flip per session).
7. **A → S**: `AcceptTrade(0x01FA)` (full `Trade::Pack` payload, but ACE only
parses the header — no fields are used). Server
(`HandleActionAcceptTrade`, `:196-216`): sets `A.TradeAccepted=true`;
**S → A**: `AcceptTrade(0x0202)` `(whoAccepted=A.Guid)` +
`CommunicationTransientString("You have accepted the offer")`; **S → B**:
`AcceptTrade(0x0202)` `(whoAccepted=A.Guid)` +
`CommunicationTransientString("{A.Name} has accepted the offer")`. If
`B.TradeAccepted` is already true, `FinalizeTrade(B)` runs now; otherwise
nothing further happens until B also sends AcceptTrade (repeats this step
for B, and then it's B's `HandleActionAcceptTrade` that finds
`target.TradeAccepted==true` and calls `FinalizeTrade`).
8. **`FinalizeTrade`** (`:218-283`), runs on whichever side's accept was
second:
- `VerifyTrade_BusyState` — if either player `IsBusy`, abort: both get a
`CommunicationTransientString` explaining who's busy, and
`ClearTradeAcceptance` fires on both (**S → both**:
`ClearTradeAcceptance(0x0208)`, no fields — see step "Failure: busy /
inventory" below).
- `VerifyTrade_Inventory` — re-resolves both `ItemsInTradeWindow` sets by
guid (if any item vanished, `HandleActionDeclineTrade` fires for that
side instead); then checks `CanAddToInventory` (burden + free-slot
capacity) for the INCOMING items on both sides. Failure → per-side
`CommunicationTransientString` (encumbered vs. no-free-slots wording)
+ `ClearTradeAcceptance` on both, trade stays open with items still in
the window.
- On success: `IsBusy=true` on both, `TradeTransferInProgress=true` on
both; **S → A** and **S → B**:
`CommunicationTransientString("The items are being traded")`.
- Escrow: for every guid in `A.ItemsInTradeWindow`,
`TryRemoveFromInventoryWithNetworking(..., RemoveFromInventoryAction.TradeItem)`
or `TryDequipObjectWithNetworking(..., DequipObjectAction.TradeItem)`
this emits the **ordinary inventory wire family**, not trade-specific
opcodes: from a pack slot →
`GameMessagePublicUpdateInstanceID(Container→Invalid)` +
`GameMessagePrivateUpdatePropertyInt(EncumbranceVal)` +
`GameMessageDeleteObject(item)` (`Player_Inventory.cs:217-247`); from an
equipped slot → `GameMessagePublicUpdateInstanceID(Wielder→Invalid)` +
`GameMessagePublicUpdatePropertyInt(CurrentWieldedLocation=0)` +
`GameMessagePickupEvent(item)` + `GameMessageSound(UnwieldObject)` +
`GameMessageDeleteObject(item)` (`Player_Inventory.cs:396-421`). Mirror
for B's items.
- After a **0.5s `ActionChain` delay**: deliver each escrowed item to its
new owner via `TryCreateInInventoryWithNetworking` — emits
`GameMessageCreateObject(item)` (+ `GameEventViewContents` and child
`GameMessageCreateObject`s if the item is itself a container) +
`GameEventItemServerSaysContainId(item, container)` +
`GameMessagePrivateUpdatePropertyInt(EncumbranceVal)`
(`Player_Inventory.cs:90-114`).
- **S → A** and **S → B**: `WeenieError.TradeComplete (0x0529)` via
`GameEventWeenieError`.
- `TradeTransferInProgress=false`, `IsBusy=false` on both;
`SaveBiotasInParallel` persists the moved items; then
`HandleActionResetTrade` runs for both sides (**S → A**, **S → B**:
`ResetTrade(0x0205)` `(whoReset=own guid)`) — this clears
`ItemsInTradeWindow`/`TradeAccepted` but leaves `IsTrading`/`TradePartner`
intact, so the window stays open, empty, for another round.
### Decline path
**Either side → S**: `DeclineTrade(0x01FB)` (no fields).
`HandleActionDeclineTrade` (`:307-323`): if `TradeTransferInProgress`, no-op
(mid-swap declines are ignored); else clears the sender's `TradeAccepted`;
**S → sender**: `DeclineTrade(0x0203)` `(whoDeclined=sender.Guid)` +
`CommunicationTransientString("Trade confirmation failed...")`; **S →
partner**: identical pair. The window stays open with items still present —
decline only clears acceptance, it does not reset or close.
### Reset path (client-initiated "clear my offered items")
**A → S**: `ResetTrade(0x0204)` (no fields). `GameActionResetTrade.Handle`
resolves `target = PlayerManager.GetOnlinePlayer(A.TradePartner)`; if found,
calls `A.HandleActionResetTrade(A.Guid)` **and**
`target.HandleActionResetTrade(A.Guid)` (`GameActionResetTrade.cs:14-19`).
Both calls run the same body (`Player_Trade.cs:177-186`): no-op if
`TradeTransferInProgress`; else clears `ItemsInTradeWindow` and
`TradeAccepted` for **whichever player's session the call executes under**,
then **S → that session**: `ResetTrade(0x0205)` `(whoReset=A.Guid)`. Net
effect: A's own window is cleared and A gets `ResetTrade`; B's window is
also cleared (same `whoReset=A.Guid` payload) and B gets `ResetTrade` too —
i.e. one player resetting clears **both** sides' offered-item lists, not
just their own. (Confirmed by reading the two-call site directly; this is
easy to misread as "reset only mine.")
### Close path
**A → S**: `CloseTradeNegotiations(0x01F7)` (no fields).
`GameActionCloseTradeNegotiations.Handle` resolves B via
`A.TradePartner`, then calls `A.HandleActionCloseTradeNegotiations()` and
`B.HandleActionCloseTradeNegotiations()` (`:12-17`).
`HandleActionCloseTradeNegotiations(endTradeReason=Normal)`
(`Player_Trade.cs:102-114`): no-op if `TradeTransferInProgress` (can't close
mid-swap); else `IsTrading=false`, `TradeAccepted=false`,
`TradeTransferInProgress=false`, `ItemsInTradeWindow.Clear()`,
`TradePartner=Invalid`; **S → that session**: `CloseTrade(0x01FF)`
`(reason)` + `WeenieError.TradeClosed (0x0451)`. Runs for both A and B, each
getting their own `CloseTrade`+`TradeClosed` pair. Items still sitting in
the window at close time are simply left in the owner's inventory/equipped
slot — closing never moves anything (only `FinalizeTrade`'s accept-accept
path does).
There is also a forced-close path: `HandleActionTradeSwitchToCombatMode`
(`:325-340`) — if a trading player enters combat mode, both sides get
`WeenieError.TradeNonCombatMode (0x0455)` then
`HandleActionCloseTradeNegotiations(EndTradeReason.EnteredCombat)`. Not
triggered by a dedicated action opcode; it's called from wherever ACE's
combat-mode-change action handler lives (not opened in this pass — grep
`GameActionChangeCombatMode.cs` if wiring this).
### Failure paths
- **Distance**: `WeenieError.TradeMaxDistanceExceeded (0x044E)` — initiator's
`CreateMoveToChain` callback failed (target moved away / unreachable)
before any `RegisterTrade` is sent. No trade session created.
- **Already trading**: `WeenieError.TradeAlreadyTrading (0x044F)` — either
side already `IsTrading`.
- **Non-combat required**: `WeenieError.TradeNonCombatMode (0x0455)` — either
side in combat mode at open time, or entering combat mid-trade (above).
- **Ignoring requests**: `WeenieError.TradeIgnoringRequests (0x044C)` — target
has `CharacterOption.IgnoreAllTradeRequests` set.
- **Attuned/pet item**: `AddToTrade` refused —
`GameEventCommunicationTransientString("You cannot trade that!")` (or the
pet-specific string) + `TradeFailure(0x0207)` with
`reason=WeenieError.AttunedItem`. Item never enters `ItemsInTradeWindow`.
- **Unique-item cap**: `AddToTrade` refused when
`wo.IsUniqueOrContainsUnique && !target.CheckUniques(...)`
`TradeFailure(0x0207)` with `reason=WeenieError.None` (ACE leaves a `//
TODO` comment at `Player_Trade.cs:156` questioning whether this should be
`TooManyUniqueItems` or a `WeenieErrorWithString` — as shipped it's the
generic `None` reason, i.e. the client gets a failure with no readable
cause).
- **Busy at finalize**: `VerifyTrade_BusyState` fails —
`CommunicationTransientString` (busy-side/other-side wording) on both +
`ClearTradeAcceptance(0x0208)` on both. Window stays open with items
in place; nothing moves.
- **Inventory can't accept at finalize**: `VerifyTrade_Inventory` fails
(encumbrance or free-slot check via `CanAddToInventory`) —
`CommunicationTransientString` (encumbered/pack-space wording, correctly
attributed to whichever side is the actual blocker) on both +
`ClearTradeAcceptance(0x0208)` on both. Window stays open, items in
place.
- **Item vanished before finalize** (e.g. someone else picked it up /
it was consumed by another concurrent action): `GetItemsInTradeWindow`
returns false for that side inside `VerifyTrade_Inventory`, which routes
into `HandleActionDeclineTrade` for the affected side — same wire as the
manual decline path (`DeclineTrade(0x0203)` + transient string to both).
## ACE quirks / landmines vs. retail
1. **`RegisterTrade` sends the wrong initiator guid.** Both S→A and S→B
`RegisterTrade` events carry `(initiator=tradePartner.Guid,
partner=tradePartner.Guid)` — i.e. the *non-initiator's* guid in both
slots, always (`Player_Trade.cs:80`, `:98`). Retail's
`Trade::Register(partnerGuid, stamp)` (decomp `0x005b9ef0`) only takes a
single `partner` argument and separately tracks `_initiator` elsewhere
in the `Trade` object, so the client is presumably reading
`initiator`/`partner` fields that ACE fills identically and incorrectly.
Whether any retail client logic actually branches on `RegisterTrade`'s
`initiator` field (vs. deriving initiator status locally) is unverified
in this pass — flag before relying on that field client-side.
2. **`OpenTrade (0x01FE)` is never sent.** Retail's dispatcher and
`Handle_Trade__Recv_OpenTrade` exist and are wired
(`DispatchUI_Recv_OpenTrade` @ `0x006acef0`), but no ACE `GameEvent*`
class emits `GameEventType.OpenTrade`. `RegisterTrade` is what actually
establishes the session; whatever retail UI behavior was gated on the
separate `OpenTrade` notice (a `partnerGuid`-only payload) never fires
against ACE.
3. **`RemoveFromTrade (0x0201)` is never sent.** Retail supports removing a
single item from the trade window without clearing the whole thing
(`Handle_Trade__Recv_RemoveFromTrade`, mode 1 = remove one, mode 2 =
remove matching quantity, calling `Trade::RemoveItem`). ACE has **no
server-side action to remove a single item** either — there is no
`GameActionType` between `AddToTrade (0x1F8)` and `AcceptTrade (0x1FA)`
reserved for it beyond the opcode gap at `0x1F9`. The only way to change
an offer on ACE is `ResetTrade (0x0204)`, which clears the **entire**
window on **both sides** (see Reset path above), not per-item removal.
Any acdream UI that lets a player "un-drag" a single item from the trade
window has nothing to send — either fake it client-side (visually pull
the item back, no wire message, and let the player re-add the rest) or
accept it maps to a full reset.
4. **`AcceptTrade`'s client-echoed state is entirely ignored server-side.**
The client packs its full local `Trade` snapshot — partner guid, a
double timestamp, status, initiator guid, both accept flags, AND the two
variable-length item lists it believes are in play — but
`GameActionAcceptTrade.Handle` (`:11-18`) reads all six fixed fields into
locals and then calls `session.Player.HandleActionAcceptTrade()` with
**zero arguments**; none of the parsed values are used. ACE derives
accept state purely from its own `TradeAccepted` bool and the two
`ItemsInTradeWindow` sets. This means a desynced client (stale local
`Trade` object) cannot corrupt the server's view, but also means ACE
does zero cross-validation against what the client thinks is in the
trade — divergence would only surface as a visual mismatch on the
client, not a security issue.
5. **`RegisterTrade`'s stamp and `AddToTrade`'s slot are hardcoded zero.**
ACE always writes `0L` for the trade timestamp
(`GameEventRegisterTrade.cs:12`) and `0` for the trade-window slot index
(`GameEventAddToTrade.cs:12`). If retail client UI ever used the
slot field to place an item visually at a specific grid position rather
than append-ordering, that positioning info is lost against ACE — items
would need to rely on arrival order instead.
6. **Distance/approach gate only applies to the initiator.** `CreateMoveToChain`
(auto-walk-to-target) only runs in the `initiator=true` branch
(`Player_Trade.cs:70-84`); the responding player's side
(`initiator=false`, `:85-99`) never re-checks distance and starts the
session unconditionally once the initiator's chain succeeds. There is no
second distance check at `AddToTrade` or `AcceptTrade` time — a trade
session, once open, has no live proximity requirement to keep offering
or accepting items even if the players walk apart afterward.
7. **`ClearTradeAcceptance (0x0208)` carries no identifying field.** Unlike
every other trade event, it has no guid/payload at all
(`GameEventClearTradeAcceptance.cs`) — the client must infer "this
applies to my own trade window" purely from receiving it on its own
session, since there's nothing to disambiguate self vs. partner (not
needed: it's always sent to the session whose acceptance was cleared).
8. **Attuned/unique-item refusals leave the item untouched, no
`RemoveFromTrade` needed** — since the item is refused before ever being
added to `ItemsInTradeWindow`, there is nothing to roll back client-side
beyond the `TradeFailure` notice.

View file

@ -0,0 +1,440 @@
# Secure-trade seam map (Lane C research)
Read-only audit. Every claim below is anchored file:line. "NOT FOUND"
means the search came up empty, not that the answer is assumed absent.
## 1. The existing option and its two current dispatch sites
Enum member: `CharacterOptionId.DragItemOnPlayerOpensSecureTrade`
(`src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs:134`, registered
`0x04000000u`). Mirrored bit constant at
`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:216` and
`src/AcDream.Core.Net/Messages/SocialActions.cs:450` (`= 0x17`, a
different unrelated numbering — that second one is a
`CharacterOptions1Bits`/switch-ordinal, not the wire bit; don't conflate
them). Read today via `RuntimeCharacterState.DragItemOnPlayerOpensSecureTrade`
(`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:629-632,721-722`),
which App threads through as a delegate in
`InteractionRetainedUiComposition.cs:325-326`:
```
dragOnPlayerOpensSecureTrade: () =>
d.Character.Options.DragItemOnPlayerOpensSecureTrade,
```
into `ItemInteractionController`'s ctor field `_dragOnPlayerOpensSecureTrade`
(`src/AcDream.App/UI/ItemInteractionController.cs:61,111,148`).
**Drag-onto-player detection path.** `ItemInteractionController.PlaceIn3D`
(`ItemInteractionController.cs:1034-1063`) is the drop handler for a
retail inventory drag released over a world/UI target
(`ItemHolder::AttemptPlaceIn3D @ 0x00588600`, per its doc comment). It
builds `ItemPlacementPolicyInput` with
`DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade()`
(line 1058) and calls `ItemInteractionPolicy.DecidePlacement` (line 1049).
The actual branch (`src/AcDream.Core/Items/ItemInteractionPolicy.cs:372-374`):
```csharp
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.StartSecureTrade,
input.Item.Id, target.Id, input.SplitSize));
if (target.Type == ItemType.Creature)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.GiveToTarget,
input.Item.Id, target.Id, input.SplitSize));
```
So the option is a straight `if`: option **true** + target is a player →
`StartSecureTrade` action; option **false** (or target not a player) +
target is any `ItemType.Creature` (players are `ItemType.Creature` too)
`GiveToTarget` action. The vanilla give path
(`GiveToTarget`) is fully wired: `ExecutePlacementActions` dispatches it
through `_sendGive``WorldSession.SendGiveObject`
(`ItemInteractionController.cs:1204-1221`, wired at
`InteractionRetainedUiComposition.cs:323-324`).
**`StartSecureTrade` today is a stub.** In `ExecutePlacementActions`'
switch, `StartSecureTrade` has no `case` — it falls to `default:`
(`ItemInteractionController.cs:1263-1268`), which invokes
`_auxiliaryAction`/`PolicyActionRequested` (both effectively unhandled
for this action kind — see §2) and otherwise shows the toast built by
`PolicyActionMessage`: `"Secure trade is not open."`
(`ItemInteractionController.cs:1296-1297`).
## 2. Use-on-selected-player today
Keybind: `InputAction.UseSelected`
`SelectionInteractionController.UseCurrentSelection()`
(`src/AcDream.App/Interaction/SelectionInteractionController.cs:71-73,217-233`).
It enqueues `RuntimeQueuedInteractionKind.Use` via `EnqueueIdentityBound`
(no target-type special-case at this layer). The queue drains through
`DispatchQueuedInteraction`
(`SelectionInteractionController.cs:842-865`):
```csharp
case RuntimeQueuedInteractionKind.Use:
_items.UseSelectedOrEnterMode(identity.ServerGuid);
break;
```
`ItemInteractionController.UseSelectedOrEnterMode`
(`ItemInteractionController.cs:553-563`) calls `ActivateItem(selectedObjectId)`
when a selection exists. `ActivateItem`
(`ItemInteractionController.cs:657-690`) builds `ItemUsePolicyInput` with
`Source: Snapshot(item)` = the SELECTED object (the target player, in
this scenario) and calls `ItemInteractionPolicy.DecideUse`.
**The retail-cited dispatch site already classifies a selected player as
OpenSecureTrade.** `ItemInteractionPolicy.DetermineUseResult`
(`src/AcDream.Core/Items/ItemInteractionPolicy.cs:181-227`, cited as
`ItemHolder::DetermineUseResult @ 0x00588460`):
```csharp
if (ItemUseability.IsUseable(item.Useability))
return ItemPrimaryUseResult.ItemUse;
if (item.IsPlayer && item.Id != playerId)
return ItemPrimaryUseResult.OpenSecureTrade;
```
(lines 220-224). `DecideUse` (lines 229-312) calls this at line 239 and,
because `OpenSecureTrade` (5) falls in the classified range
`[PlaceInBackpack(2)..BeginGame(7)]` (line 241-242 comment: "Exact
retail bound: UseObject classifies 2..7, deliberately excluding 8"),
routes to `BuildUsingItemActions`, which maps
`ItemPrimaryUseResult.OpenSecureTrade => ItemPolicyActionKind.OpenSecureTrade`
(confirmed mapping near line 407 of the same file).
So: **pressing Use with another player selected already produces an
`OpenSecureTrade` policy action end-to-end through the ported retail
classifier** — no new classification logic is needed. The gap is purely
on the execution side: in `ExecuteUseActions`'
switch, `OpenSecureTrade` has no `case` and falls to the same `default:`
stub as `StartSecureTrade` (`ItemInteractionController.cs:1142-1149`),
producing the identical "Secure trade is not open." toast
(`PolicyActionMessage`, line 1296-1297).
**Where a trade-open branch goes:** add
`case ItemPolicyActionKind.OpenSecureTrade:` /
`case ItemPolicyActionKind.StartSecureTrade:` to both
`ExecuteUseActions` (`ItemInteractionController.cs:1072-1150`) and
`ExecutePlacementActions` (`ItemInteractionController.cs:1160-1271`),
each calling a new delegate (mirroring `_sendGive`) that opens the trade
window / sends the wire open request with `action.TargetId`.
`RetailItemConfirmationController`
(`src/AcDream.App/UI/RetailItemConfirmationController.cs:41-56`) is the
only current subscriber of `PolicyActionRequested`, and it only handles
`ConfirmPlayerKillerSwitch`/`ConfirmNonPlayerKillerSwitch`/
`ConfirmVolatileRare` — it silently ignores `OpenSecureTrade`/
`StartSecureTrade` (`message is null` → early return, line 50-51). A new
trade controller subscribing to the same event is a viable second wiring
point if a dedicated ItemInteractionController delegate isn't preferred,
but the delegate approach matches how `GiveToTarget` is wired (a named
`_sendGive` ctor param, not the generic auxiliary-action escape hatch).
## 3. Vendor panel as the mount template
`VendorUiController` (`src/AcDream.App/UI/Layout/VendorUiController.cs`)
+ its mount method `RetailUiRuntime.MountVendor`
(`src/AcDream.App/UI/RetailUiRuntime.cs:3368-3471`). Recipe:
1. Under `_bindings.Assets.DatLock`, import the LayoutDesc via
`LayoutImporter.Import(dats, VendorUiController.LayoutId, VendorUiController.RootId, ...)`
(lines 3374-3382) and resolve any empty-slot sprites needed for item
strips via `ItemListCellTemplate.ResolveEmptySprite` (lines 3386-3401).
2. `RetailWindowFrame.Mount(Host.Root, root, _bindings.Assets.ResolveSprite, new RetailWindowFrame.Options { WindowName = WindowNames.Vendor, Chrome = ..., Left/Top/ContentWidth/ContentHeight from root, Visible = false, Resize flags, ConstrainDragToParent/ConstrainResizeToParent, DrawChromeCenter })`
(lines 3410-3434) — returns a `RetailWindowHandle`.
3. `VendorController = VendorUiController.Bind(layout, b.State, handle,
b.ResolveIcon, _bindings.Inventory.Objects, _bindings.Inventory.PlayerGuid,
b.ItemInteraction, b.Selection, StackSplitQuantity,
_bindings.Assets.DefaultFont, _bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite, emptySlotSprite, buyingEmptySlotSprite,
sellingEmptySlotSprite, DialogFactory, b.DisplaySystemMessage)`
(lines 3443-3462) where `b = _bindings.Vendor` (a `VendorRuntimeBindings`,
`RetailUiRuntime.cs:340-354`).
4. `Host.WindowManager.AttachController(WindowNames.Vendor, VendorController)`
(line 3469).
Same shape used by the social panel's `MountSocialPanel`
(`RetailUiRuntime.cs:2741-2965`), which additionally shows the
`ActivateTabs()` call for tabbed panels (line 2929) and
`_panelUi.RegisterMainPanel(...)` for panel-catalog/toolbar-button
registration (lines 2956-2963) — a secure-trade window is a two-sided
non-tabbed floating window like Vendor, so `MountVendor` is the closer
template.
**Where mount methods get called from:** `RetailUiRuntime.Initialize()`
(`RetailUiRuntime.cs:455-484`) calls every `MountXxx()` in a fixed
sequence, e.g. `MountSocialPanel(); MountCharacter(); MountPlugins();
MountInventory(); MountExternalContainer(); MountVendor();
MountItemCooldowns();` (lines 475-481). A `MountSecureTrade()` call
would join this list, most naturally right after `MountVendor()` since
both are two-participant item-exchange windows sharing icon/drag
machinery.
**Runtime state callback shape:** `VendorRuntimeBindings`
(`RetailUiRuntime.cs:340-354`) is built in
`InteractionRetainedUiComposition.cs:804-809`:
```csharp
Vendor: new VendorRuntimeBindings(
d.Inventory.Vendor,
iconComposer.GetIcon,
itemInteraction,
d.Actions.Selection,
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
```
i.e. it hands the controller the live `VendorState` object directly
(not a snapshot func) plus the icon resolver, item-interaction
controller, selection state, and a system-message sink. A
`TradeRuntimeBindings` record would follow the identical shape: a live
`RuntimeTradeState` (or its view), icon resolver, item interaction,
selection, message sink.
## 4. Runtime owner shape
`RuntimeInventoryState` (`src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`)
is constructed at `GameRuntime.cs:201-207`:
```csharp
context.Inventory = new RuntimeInventoryState(context.EntityObjects);
```
— it takes the shared `RuntimeEntityObjectLifetime` (constructed at
`GameRuntime.cs:191-199`) and exposes the SAME `ClientObjectTable` via
`Objects => _entityObjects.Objects` (`RuntimeInventoryState.cs:78`); it
creates no second object model. Its own children
(`ExternalContainers`, `ItemMana`, `Shortcuts`, `Transactions`,
`Vendor`, `VendorItems`) are constructed in its ctor
(`RuntimeInventoryState.cs:53-76`) — `Vendor = new VendorState()` at
line 67 is the closest existing analogue to a future `Trade` child:
**vendor state lives as a child of `RuntimeInventoryState`, not as a
GameRuntime-level sibling**, whereas Fellowship/Allegiance are top-level
siblings (`GameRuntime.cs:236,244`). A secure-trade owner has a
plausible case for either shape — it manipulates inventory items (favors
the Vendor precedent, nested under `RuntimeInventoryState`) but also has
its own two-party negotiation lifecycle independent of container state
(favors the Fellowship/Allegiance precedent, a GameRuntime-level
sibling). Either is a straight port of an existing pattern; no third
shape needs inventing.
**Generation reset:** `RuntimeGenerationReset`
(`src/AcDream.Runtime/RuntimeGenerationReset.cs`) is constructed with
every owner needing session-scoped clearing, including
`_fellowship`/`_allegiance` (ctor params, lines 112-113,129-130) and
drives `_inventory.ResetVendor()` at its `Vendor`-family stage (line
288) and `_fellowship.ResetSession()` / `_allegiance.ResetSession()` at
their own stages (lines 317-321, enum values `Fellowship = 12`,
`Allegiance = 13` at lines 41,55). A new trade owner needs either a new
`ResetTrade()` call folded into the existing Vendor-family reset stage
(if nested under Inventory) or its own new
`RuntimeGenerationResetStage` entry + ctor param (if a GameRuntime-level
sibling) — same file, same pattern either way.
**`_bindings.Social`/`_bindings.Inventory` reach path (App side):**
`d.Inventory.Vendor` in `InteractionRetainedUiComposition.cs:805` and
`d.Runtime.Fellowship`/`d.Runtime.Allegiance` in the Social binding block
(`InteractionRetainedUiComposition.cs:890-892`,
`() => d.Runtime.Fellowship.Snapshot`) show the two reach patterns: a
direct owned-state object (`d.Inventory.Vendor`, mutable, App reads it
live) vs. a `IGameRuntimeView`-typed snapshot accessor
(`d.Runtime.Fellowship.Snapshot`, immutable projection). `d.Inventory`
and `d.Runtime` are both fields on `InteractionRetainedUiDependencies`
(same file, referenced throughout — not independently re-verified here
since both usages above are load-bearing evidence of the shape).
**(a) Inbound events reaching the owner** — the FellowshipUpdate/
FriendsUpdate routing pattern, in two hops:
1. `GameEventWiring.RegisterAll` (or its per-domain overload) exposes
optional `Action<T>?` delegate holes per parsed event type, e.g.
`onFellowshipUpdateFellow` (`src/AcDream.Core.Net/GameEventWiring.cs:108`,
registered conditionally at lines 252-258:
`registrar.Register(GameEventType.FellowshipUpdateFellow, e => { var update = GameEvents.ParseFellowshipUpdateFellow(e.Payload.Span); if (update is not null) onFellowshipUpdateFellow(update.Value); })`).
2. `LiveSessionEventRouter` (`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs`)
wires those holes to the Runtime owner's `Apply*` methods,
conditionally on the owner being supplied (lines 256-284):
```csharp
onFellowshipUpdateFellow: social.Fellowship is { } fellowshipUpdate
? fellowshipUpdate.ApplyUpdateFellow
: null,
```
where `social` is a `LiveSocialSessionBindings` record
(`LiveSessionEventRouter.cs:72-88`) carrying `Fellowship`/`Allegiance`
owner references.
3. `LiveSessionRuntimeFactory`
(`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:258-273`)
constructs the router and supplies the actual owners:
```csharp
var route = new LiveSessionEventRouter(
...,
new LiveSocialSessionBindings(
...,
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner));
```
`GameRuntime.FellowshipOwner`/`AllegianceOwner` are typed getters over
the same `context.Fellowship`/`context.Allegiance` fields
(`GameRuntime.cs:463-464`).
A trade owner's inbound wiring is the same three-hop shape: parse
`GameEventType.OpenTrade`/`AddToTrade`/`AcceptTrade`/etc in
`GameEvents.cs` (partially started — see §5), add delegate holes +
conditional registration in `GameEventWiring.cs`, add a
`Trade`/`RuntimeTradeState?` field to a bindings record analogous to
`LiveSocialSessionBindings`, and supply `_domain.Runtime.TradeOwner` at
the `LiveSessionRuntimeFactory.cs:258-273` construction site.
**(b) UI borrowing it:** the `_bindings.Social` record shape
(`SocialRuntimeBindings`, `RetailUiRuntime.cs:264-286`) is a flat record
of `Func<TSnapshot>` accessors + command delegates + shared `SelectionState`/
`LocalPlayerGuid` accessors, built in
`InteractionRetainedUiComposition.cs:890-...` by closing over
`d.Runtime.Fellowship`/`d.Runtime.Allegiance` for reads and
`late.GameRuntime.FellowshipXxx(...)` (a `DeferredGameRuntimeStateCommands`
instance, `src/AcDream.App/Composition/InteractionUiRuntimeSources.cs:23-140`)
for generation-gated writes. Each `DeferredGameRuntimeStateCommands`
method (e.g. `FellowshipCreate`, lines 126-128) calls
`Invoke((commands, generation) => commands.Fellowship.Create(generation, ...))`
against an `IGameRuntimeCommands` interface, whose concrete
`DirectGameRuntimeCommandAdapter` implementation
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:804-825`
for `Create`) validates the generation token then calls the matching
`WorldSession.SendXxx`. A `TradeRuntimeBindings` + trade commands on
`IGameRuntimeCommands` would follow this exact three-layer shape
(App binding record → `DeferredGameRuntimeStateCommands` method →
`IGameRuntimeCommands.Trade.Xxx(generation, ...)`
`DirectGameRuntimeCommandAdapter``WorldSession.SendXxx`).
## 5. WorldSession send pattern
All outbound sends in `src/AcDream.Core.Net/WorldSession.cs` follow:
```csharp
uint seq = NextGameActionSequence();
SendGameAction(SomeRequests.BuildSomething(seq, ...args));
```
Three examples:
- `SendDropItem(uint itemGuid)``WorldSession.cs:2558-2562`.
- `SendGiveObject(uint targetGuid, uint itemGuid, uint amount)`
`WorldSession.cs:2569-2574`, builder `InventoryActions.BuildGiveObjectRequest`.
- `SendAppraise(uint targetGuid)``WorldSession.cs:2648-2652`, builder
`AppraiseRequest.Build`.
The builder classes live in `src/AcDream.Core.Net/Messages/` (one static
class per message family, e.g. `VendorRequests.cs` for Buy/Sell,
`InventoryActions.cs` for drop/give/wield). `VendorRequests.BuildBuy`
(`src/AcDream.Core.Net/Messages/VendorRequests.cs:54-70+`) is the
richest documented example: constants for envelope/opcode
(`GameActionEnvelope = 0xF7B1u`, `BuyOpcode = 0x005Fu`) and an
extensive doc comment citing the retail decompiled sender + 3 other
cross-checked references for the wire layout — the expected citation
depth for a new `TradeRequests.BuildOpenTrade`/`BuildAddToTrade`/etc.
**NOT FOUND: no `TradeRequests`/`TradeActions` builder class exists yet**
(grepped `src/` for both names — the only hits are an unrelated
`IgnoreTradeRequests` character-option enum member,
`src/AcDream.Core.Net/Messages/SocialActions.cs:430`). **NOT FOUND: no
`WorldSession.SendXxx` for any trade opcode** (grepped `WorldSession.cs`
for "Trade" — only comments about the chat "Trade" room, e.g. line 467).
Every trade send must be built from scratch on this pattern.
**Partial scaffolding that DOES exist:** `GameEventType` already has the
ten trade opcodes (`src/AcDream.Core.Net/Messages/GameEventType.cs:62-70`:
`RegisterTrade = 0x01FD`, `OpenTrade = 0x01FE`, `CloseTrade = 0x01FF`,
`AddToTrade = 0x0200`, `RemoveFromTrade = 0x0201`,
`AcceptTrade = 0x0202`, `DeclineTrade = 0x0203`, `ResetTrade = 0x0205`,
`TradeFailure = 0x0207`, `ClearTradeAcceptance = 0x0208`), and
`GameEvents.cs` has three inbound parsers already written but
**unregistered** anywhere: `ParseTradeFailure` (line 466),
`ParseAddToTrade``record struct AddToTrade(uint ItemGuid, uint SlotIndex)`
(lines 472-478), `ParseAcceptTrade` (line 483-484+). `GameEventWiring.cs`
has no `Register(GameEventType.OpenTrade, ...)` etc. — grepped and only
found unrelated chat-room "Trade" comments. So inbound parsing is
started but not wired; outbound building doesn't exist at all;
`ItemPolicyObject.TradeState` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs:75`)
already exists as a field consumed by `DecidePlacement`/`DecideUse`
(e.g. "You cannot move an item while it is being traded." at line 354,
"You cannot use an item while it is being traded." at line 248) —
confirming the POLICY layer already expects a `TradeState` concept even
though nothing produces it live yet.
## 6. Icon rendering for item lists
Vendor's shop-item rows resolve icons via a bound
`Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon` — the same
signature that `VendorRuntimeBindings.ResolveIcon`
(`RetailUiRuntime.cs:342`) carries, sourced from
`iconComposer.GetIcon` (`InteractionRetainedUiComposition.cs:806`).
Call site in `VendorUiController`
(`src/AcDream.App/UI/Layout/VendorUiController.cs:1090-1106`):
```csharp
uint icon = _resolveIcon(
(ItemType)(item.ItemType ?? 0u),
item.IconId,
item.IconUnderlayId,
item.IconOverlayId,
item.Effects);
var cell = new UiItemSlot { SpriteResolve = _itemList.SpriteResolve, SlotIndex = ..., AllowDragSource = false };
cell.SetItem(item.ItemGuid, icon);
```
A second call site at `VendorUiController.cs:2199-2203` (shop item, a
different list) and a third at `VendorUiController.cs:2240-2241`
(`item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId,
item.Effects` — a `ClientObject`-sourced variant, for player-owned items
being sold) confirm the pattern generalizes across both "vendor stock"
and "player inventory" rows — exactly the two sides a trade window
needs (local player's staged items + remote player's staged items, both
rendered as `UiItemSlot` rows with the same `ResolveIcon` delegate).
`UiItemSlot.SetItem(guid, iconSpriteId)` is the shared cell-population
call every item list in the codebase uses (vendor, external container,
inventory).
## 7. Test templates
- **Wire builder test (Core.Net.Tests):**
`tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs:1-40+`
`VendorRequestsTests.BuildBuy_SingleItem_...` asserts exact byte
offsets (envelope/seq/opcode/args) via `BinaryPrimitives.ReadUInt32LittleEndian`
over the returned `byte[]`. A `TradeRequestsTests.cs` would follow this
shape per new opcode (OpenTrade/AddToTrade/AcceptTrade/etc).
`tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs` is the
matching template for the INBOUND parser side (asserting
`GameEvents.ParseXxx` against constructed payload bytes).
- **Runtime owner test:**
`tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs:1-30+`
— constructs `GameEvents.FellowMember` fixtures and exercises full-update
assembly, incremental upsert, self-vs-other removal, revision
monotonicity, ownership convergence. Direct template for a
`RuntimeTradeStateTests.cs`.
- **Panel controller test with fixtures:**
`tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs:1-40+`
a hand-built `ImportedLayout` over `RetailWindowFrame.Mount` for the
behavioral suite, PLUS one real-DAT-fixture smoke test (its own doc
comment cites `AppraisalUiControllerTests`'s `FixtureLoader` use) that
catches drift between hardcoded element ids and the actual LayoutDesc.
Direct template for a `TradeUiControllerTests.cs`.
## Summary of the actionable seam list
1. `ItemPolicyActionKind.StartSecureTrade` and `.OpenSecureTrade` are
both ALREADY produced by the ported retail classifier
(drag-onto-player and Use-on-selected-player respectively) — the only
gap is execution. Add explicit `case` arms in
`ItemInteractionController.ExecuteUseActions` (line ~1072) and
`.ExecutePlacementActions` (line ~1160), each invoking a new
ctor-injected delegate (mirroring `_sendGive`) rather than falling to
the generic `_auxiliaryAction`/`PolicyActionRequested` stub.
2. No wire builder exists for any trade opcode — write
`src/AcDream.Core.Net/Messages/TradeRequests.cs` (outbound) following
`VendorRequests.cs`'s documented-citation shape, and finish
`GameEvents.cs`'s partial inbound parsers (3 of ~10 opcodes started)
plus register them all in `GameEventWiring.cs` (currently zero trade
registrations).
3. Add `WorldSession.SendXxx` methods for each trade opcode
(`WorldSession.cs`, next to `SendGiveObject`/`SendBuy`).
4. New Runtime owner `RuntimeTradeState` — decide nested-under-
`RuntimeInventoryState` (Vendor precedent) vs. GameRuntime-level
sibling (Fellowship/Allegiance precedent); wire construction in
`GameRuntime.cs`, reset in `RuntimeGenerationReset.cs`, inbound
routing through `GameEventWiring``LiveSessionEventRouter`
`LiveSessionRuntimeFactory.cs:258-273`, and commands through
`IGameRuntimeCommands``DirectGameRuntimeCommandAdapter`
the new `WorldSession.SendXxx` calls.
5. New `TradeRuntimeBindings` record (mirror `VendorRuntimeBindings`,
`RetailUiRuntime.cs:340-354`) built in
`InteractionRetainedUiComposition.cs` alongside the `Vendor:`/`Social:`
blocks, and a `TradeUiController` + `MountSecureTrade()` mounted from
`RetailUiRuntime.Initialize()` next to `MountVendor()`
(`RetailUiRuntime.cs:480`), reusing `ResolveIcon`/`UiItemSlot.SetItem`
for both parties' staged-item rows.