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>
364 lines
24 KiB
Markdown
364 lines
24 KiB
Markdown
# 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).
|