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:
parent
bee38b0746
commit
067cbea8a5
26 changed files with 2682 additions and 42 deletions
364
docs/research/2026-08-14-trade-laneA-ui.md
Normal file
364
docs/research/2026-08-14-trade-laneA-ui.md
Normal 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).
|
||||
316
docs/research/2026-08-14-trade-laneB-wire.md
Normal file
316
docs/research/2026-08-14-trade-laneB-wire.md
Normal 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.
|
||||
440
docs/research/2026-08-14-trade-laneC-seams.md
Normal file
440
docs/research/2026-08-14-trade-laneC-seams.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue