acdream/docs/research/2026-08-14-trade-laneA-ui.md
Erik 067cbea8a5 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>
2026-08-14 11:49:13 +02:00

364 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).