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

authored gmSecureTradeUI window, and both retail open paths

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

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

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

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

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -923,7 +923,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
AllegianceSetUpdateSubscription: on =>
late.GameRuntime.AllegianceSetUpdateSubscription(on)),
late.GameRuntime.AllegianceSetUpdateSubscription(on),
Trade: d.Runtime.Trade),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -33,6 +33,13 @@ internal sealed record LiveSessionCommandBindings(
Action<uint> RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
// Secure trade (2026-08-14) — the CM_Trade senders.
Action<uint> OpenTradeNegotiations,
Action CloseTradeNegotiations,
Action<uint> AddToTrade,
Action<uint, bool, bool> AcceptTrade,
Action DeclineTrade,
Action ResetTrade,
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch,
@ -92,6 +99,17 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
internal readonly record struct AddFriendRuntimeCmd(string Name);
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
// ── Secure trade (2026-08-14) ──────────────────────────────────────────────
internal readonly record struct OpenTradeNegotiationsRuntimeCmd(uint PartnerGuid);
internal readonly record struct CloseTradeNegotiationsRuntimeCmd;
internal readonly record struct AddToTradeRuntimeCmd(uint ItemGuid);
internal readonly record struct AcceptTradeRuntimeCmd(
uint PartnerGuid,
bool SelfAccepted,
bool PartnerAccepted);
internal readonly record struct DeclineTradeRuntimeCmd;
internal readonly record struct ResetTradeRuntimeCmd;
internal readonly record struct ClearFriendsRuntimeCmd;
internal readonly record struct RequestLegacyFriendsRuntimeCmd;
internal readonly record struct ModifyCharacterSquelchRuntimeCmd(
@ -210,6 +228,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
_ => SendIfActive(bindings.SaveCharacterOptions));
commands.Register<AddFriendRuntimeCmd>(
command => SendIfActive(() => bindings.AddFriend(command.Name)));
commands.Register<OpenTradeNegotiationsRuntimeCmd>(
command => SendIfActive(() =>
bindings.OpenTradeNegotiations(command.PartnerGuid)));
commands.Register<CloseTradeNegotiationsRuntimeCmd>(
_ => SendIfActive(bindings.CloseTradeNegotiations));
commands.Register<AddToTradeRuntimeCmd>(
command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid)));
commands.Register<AcceptTradeRuntimeCmd>(
command => SendIfActive(() => bindings.AcceptTrade(
command.PartnerGuid,
command.SelfAccepted,
command.PartnerAccepted)));
commands.Register<DeclineTradeRuntimeCmd>(
_ => SendIfActive(bindings.DeclineTrade));
commands.Register<ResetTradeRuntimeCmd>(
_ => SendIfActive(bindings.ResetTrade));
commands.Register<RemoveFriendRuntimeCmd>(
command => SendIfActive(() =>
bindings.RemoveFriend(command.CharacterId)));

View file

@ -270,7 +270,8 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Communication.Squelch,
(text, type) => _domain.Communication.AddText(text, type),
Fellowship: _domain.Runtime.FellowshipOwner,
Allegiance: _domain.Runtime.AllegianceOwner));
Allegiance: _domain.Runtime.AllegianceOwner,
Trade: _domain.Runtime.TradeOwner));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
@ -596,6 +597,18 @@ internal sealed class LiveSessionRuntimeFactory
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
// Secure trade (2026-08-14): AcceptTrade's echoed payload is
// ACE-discarded (lane B); the initiator field carries the partner
// guid — the only initiator identity ACE itself ever put on the
// wire (the RegisterTrade landmine).
OpenTradeNegotiations: session.SendOpenTradeNegotiations,
CloseTradeNegotiations: session.SendCloseTradeNegotiations,
AddToTrade: item => session.SendAddToTrade(item),
AcceptTrade: (partner, selfAccepted, partnerAccepted) =>
session.SendAcceptTrade(
partner, 0d, 0u, partner, selfAccepted, partnerAccepted),
DeclineTrade: session.SendDeclineTrade,
ResetTrade: session.SendResetTrade,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,

View file

@ -182,6 +182,16 @@ public sealed class ItemInteractionController : IDisposable
public event Action? StateChanged;
/// <summary>
/// Retail's two secure-trade open paths surface here for the trade UI:
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
/// (<c>DetermineUseResult @ 0x00588460</c> result 5), non-zero for
/// drag-item-onto-player with the DragItemOnPlayerOpensSecureTrade
/// option (<c>AttemptPlaceIn3D @ 0x00588600</c>). The subscriber
/// (SecureTradeUiController) owns the open/stage sequencing.
/// </summary>
public event Action<uint, uint>? SecureTradeRequested;
/// <summary>
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
/// panel inserts a waiting projection before the pickup request is sent.
@ -1139,6 +1149,14 @@ public sealed class ItemInteractionController : IDisposable
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
break;
case ItemPolicyActionKind.OpenSecureTrade:
// Use-on-player (ItemHolder::DetermineUseResult
// @ 0x00588460 result 5 → ClientTradeSystem::
// AttemptToOpenTradeNegotiations @ 0x0056DEE0). The
// action's ObjectId IS the target player.
SecureTradeRequested?.Invoke(action.ObjectId, 0u);
acted |= SecureTradeRequested is not null;
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
@ -1161,6 +1179,13 @@ public sealed class ItemInteractionController : IDisposable
{
switch (action.Kind)
{
case ItemPolicyActionKind.StartSecureTrade:
// Drag-item-onto-player with DragItemOnPlayerOpensSecureTrade
// (ItemHolder::AttemptPlaceIn3D @ 0x00588600's option branch
// → ClientTradeSystem::AttemptToTradeItem @ 0x0056DF80).
// ObjectId = the dragged item, TargetId = the player.
SecureTradeRequested?.Invoke(action.TargetId, action.ObjectId);
break;
case ItemPolicyActionKind.DropToWorld:
TryDispatchInventoryRequest(
InventoryRequestKind.DropToWorld,

View file

@ -0,0 +1,293 @@
using System.Numerics;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported retail <c>gmSecureTradeUI</c> layout
/// (LayoutDesc <c>0x2100000D</c>, root <c>0x1000007A</c>) to
/// <see cref="RuntimeTradeState"/>'s view. No panel geometry is synthesized:
/// every control is the authored element.
/// </summary>
/// <remarks>
/// Retail references (lane A, docs/research/2026-08-14-trade-laneA-ui.md):
/// <c>gmSecureTradeUI::PostInit @ 0x004CA160</c> binds exactly these ids;
/// <c>ListenToElementMessage @ 0x004CAE80</c> reacts to the Trade button
/// (0x10000086), Clear All (0x1000008A), and the close X (0x1000008B);
/// <c>RecvNotice_RegisterTrade @ 0x004CA5C0</c> opens the panel. The accept
/// presentation follows lane A's recommendation: driven off the owner's own
/// accepted booleans (the partner status icon's authored 'Highlight' state;
/// the Trade button's Selected latch), not retail's ambiguous literal
/// m_state numbers. Open paths (retail's own two):
/// <c>ItemHolder::DetermineUseResult @ 0x00588460</c> result 5 (Use on a
/// player) and <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>'s
/// DragItemOnPlayerOpensSecureTrade branch → both surface here through
/// <see cref="RequestSecureTrade"/> (the ItemInteractionController event),
/// mirroring <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
/// @ 0x0056DEE0</c> / <c>AttemptToTradeItem @ 0x0056DF80</c> — the latter's
/// "queue the dragged item until the window registers" is the pending-stage
/// latch consumed in <see cref="Tick"/>.
/// </remarks>
public sealed class SecureTradeUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100000Du;
public const uint RootId = 0x1000007Au;
public const uint PartnerNameId = 0x1000007Eu;
public const uint PartnerStatusId = 0x1000007Fu;
public const uint PartnerCountId = 0x10000080u;
public const uint PartnerListId = 0x10000081u;
public const uint SelfNameId = 0x10000085u;
public const uint TradeButtonId = 0x10000086u;
public const uint SelfCountId = 0x10000087u;
public const uint SelfListId = 0x10000088u;
public const uint ClearAllButtonId = 0x1000008Au;
public const uint CloseButtonId = 0x1000008Bu;
/// <summary>The authored partner-status accept cue (probe: element
/// 0x1000007F states '', 'Highlight', 'Ghosted').</summary>
private const string AcceptedState = "Highlight";
public sealed record Bindings(
IRuntimeTradeView Trade,
ClientObjectTable Objects,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Action<uint> OpenTrade,
Action CloseTrade,
Action<uint> AddToTrade,
Action<bool /*selfAccepted*/, bool /*partnerAccepted*/, uint /*partner*/> AcceptTrade,
Action DeclineTrade,
Action ResetTrade,
Action<bool> SetWindowVisible);
private readonly Bindings _bindings;
private readonly UiText? _partnerName;
private readonly UiElement? _partnerStatus;
private readonly UiText? _partnerCount;
private readonly UiItemList? _partnerList;
private readonly UiText? _selfCount;
private readonly UiItemList? _selfList;
private readonly UiButton? _tradeButton;
private long _lastRevision = long.MinValue;
private bool _wasOpen;
private uint _pendingPartner;
private uint _pendingStageItem;
private bool _disposed;
private SecureTradeUiController(
ImportedLayout layout,
Bindings bindings)
{
_bindings = bindings;
_partnerName = layout.FindElement(PartnerNameId) as UiText;
_partnerStatus = layout.FindElement(PartnerStatusId);
_partnerCount = layout.FindElement(PartnerCountId) as UiText;
_partnerList = layout.FindElement(PartnerListId) as UiItemList;
_selfCount = layout.FindElement(SelfCountId) as UiText;
_selfList = layout.FindElement(SelfListId) as UiItemList;
_tradeButton = layout.FindElement(TradeButtonId) as UiButton;
if (_tradeButton is not null)
{
// Retail's accept TOGGLE: not-yet-accepted click → AcceptTrade;
// already-accepted click → DeclineTrade (withdraw). Selected is
// seeded from the store each Tick (the CH6a/b mirror discipline).
_tradeButton.SuppressSelfToggle = true;
_tradeButton.OnClick = () =>
{
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
if (!snapshot.IsOpen) return;
if (snapshot.SelfAccepted)
_bindings.DeclineTrade();
else
_bindings.AcceptTrade(
true, snapshot.PartnerAccepted, snapshot.PartnerGuid);
};
}
if (layout.FindElement(ClearAllButtonId) is UiButton clearAll)
clearAll.OnClick = () =>
{
if (_bindings.Trade.Snapshot.IsOpen) _bindings.ResetTrade();
};
if (layout.FindElement(CloseButtonId) is UiButton close)
close.OnClick = () =>
{
if (_bindings.Trade.Snapshot.IsOpen) _bindings.CloseTrade();
};
// Retail registers the drag handler on the SELF grid only
// (PostInit @ 0x004CA1F7; drops land only on your own side —
// HandleDropRelease's ancestor-chain check). An inventory item
// dropped on the grid stages it.
_selfList?.RegisterDragHandler(new SelfGridDropHandler(this));
_bindings.SetWindowVisible(false);
}
public static SecureTradeUiController? Bind(
ImportedLayout layout, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(bindings);
// The two grids are the panel's load-bearing controls; a layout
// missing either cannot present a trade honestly.
if (layout.FindElement(SelfListId) is not UiItemList
|| layout.FindElement(PartnerListId) is not UiItemList)
return null;
return new SecureTradeUiController(layout, bindings);
}
/// <summary>
/// The two retail open paths (Use-on-player, drag-item-on-player) —
/// raised by ItemInteractionController. When a trade with this partner
/// is already open, a dragged item stages immediately
/// (<c>AttemptToTradeItem</c>'s open-trade branch); otherwise open
/// negotiations and latch the item until RegisterTrade arrives.
/// </summary>
public void RequestSecureTrade(uint partnerGuid, uint itemGuid)
{
if (_disposed || partnerGuid == 0u) return;
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
if (snapshot.IsOpen && snapshot.PartnerGuid == partnerGuid)
{
if (itemGuid != 0u) _bindings.AddToTrade(itemGuid);
return;
}
_pendingPartner = partnerGuid;
_pendingStageItem = itemGuid;
_bindings.OpenTrade(partnerGuid);
}
/// <summary>Applies the latest owner snapshot (revision-gated).</summary>
public void Tick()
{
if (_disposed) return;
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
if (snapshot.IsOpen && !_wasOpen)
{
_wasOpen = true;
_bindings.SetWindowVisible(true);
// AttemptToTradeItem's queued item — stage it now that the
// window registered, if the register matched the request.
if (_pendingStageItem != 0u
&& (_pendingPartner == 0u
|| snapshot.PartnerGuid == _pendingPartner))
{
_bindings.AddToTrade(_pendingStageItem);
}
_pendingStageItem = 0u;
_pendingPartner = 0u;
}
else if (!snapshot.IsOpen && _wasOpen)
{
_wasOpen = false;
_bindings.SetWindowVisible(false);
}
if (snapshot.Revision == _lastRevision) return;
_lastRevision = snapshot.Revision;
if (_partnerName is not null)
{
string name = _bindings.Objects.Get(snapshot.PartnerGuid)
?.GetAppropriateName() ?? string.Empty;
_partnerName.LinesProvider =
() => [new UiText.Line(name, Vector4.One)];
}
// Accept cues: the partner icon's authored Highlight state (the same
// ActiveState flip the fellowship row's amber selection uses); the
// Trade button's Selected latch for the local player's own accept.
if (_partnerStatus is UiDatElement status)
status.ActiveState = snapshot.PartnerAccepted ? AcceptedState : "";
if (_tradeButton is not null)
_tradeButton.Selected = snapshot.SelfAccepted;
SetCount(_selfCount, snapshot.SelfItemCount);
SetCount(_partnerCount, snapshot.PartnerItemCount);
Populate(_selfList, RuntimeTradeSide.Self);
Populate(_partnerList, RuntimeTradeSide.Partner);
}
public void SyncVisibility()
{
_wasOpen = !_bindings.Trade.Snapshot.IsOpen; // force re-evaluate
Tick();
}
public void OnShown() => Tick();
private void SetCount(UiText? text, int count)
{
if (text is null) return;
// Numeric-only, the AD-85 numeric-fields disposition: the authored
// ID_SecureTrade_TotalItemsLabel template's variable shape is
// unverified, so the DATA shows without invented surrounding words.
string line = count.ToString();
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
}
private void Populate(UiItemList? list, RuntimeTradeSide side)
{
if (list is null) return;
using (list.DeferLayout())
{
list.Flush();
foreach (uint guid in _bindings.Trade.GetItems(side))
{
ClientObject? item = _bindings.Objects.Get(guid);
uint icon = item is null ? 0u : _bindings.ResolveIcon(
item.Type,
item.IconId,
item.IconUnderlayId,
item.IconOverlayId,
item.Effects);
var cell = new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
// Staged rows are not drag sources — ACE has no
// per-item removal (only Clear All / reset).
AllowDragSource = false,
};
cell.SetItem(guid, icon);
list.AddItem(cell);
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_tradeButton is not null) _tradeButton.OnClick = null;
}
/// <summary>Drops on the SELF grid stage the dragged inventory item
/// (retail's AcceptDragObject → AddToTrade path).</summary>
private sealed class SelfGridDropHandler(SecureTradeUiController owner)
: IItemListDragHandler
{
public void OnDragLift(
UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
// The trade grids are never drag SOURCES (AllowDragSource=false
// on every staged cell) — nothing to lift.
}
public ItemDragAcceptance OnDragOver(
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.SourceKind == ItemDragSource.Inventory
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
public void HandleDropRelease(
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
if (payload.SourceKind != ItemDragSource.Inventory) return;
if (owner._bindings.Trade.Snapshot.IsOpen)
owner._bindings.AddToTrade(payload.ObjId);
}
}
}

View file

@ -283,7 +283,10 @@ public sealed record SocialRuntimeBindings(
Func<uint, RuntimeCommandResult> AllegianceSwear,
Func<uint, RuntimeCommandResult> AllegianceBreak,
Func<uint, RuntimeCommandResult> AllegianceKick,
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription);
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription,
// Secure trade (2026-08-14): the third sibling J-owner's borrowed view.
// Trailing/optional per the established compatibility convention.
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -478,6 +481,7 @@ public sealed class RetailUiRuntime : IDisposable
MountInventory();
MountExternalContainer();
MountVendor();
MountSecureTrade();
MountItemCooldowns();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
@ -607,6 +611,7 @@ public sealed class RetailUiRuntime : IDisposable
LinkStatusUiController?.Tick();
IndicatorBarController?.Tick();
JumpPowerbarController?.Tick();
SecureTradeController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
@ -3470,6 +3475,102 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
}
/// <summary>The mounted secure-trade window's controller — null until
/// <see cref="MountSecureTrade"/> runs (or when the trade view/layout is
/// unavailable).</summary>
public Layout.SecureTradeUiController? SecureTradeController { get; private set; }
private void MountSecureTrade()
{
if (_bindings.Social.Trade is not { } tradeView)
{
Console.WriteLine("[M4] secure trade: no runtime trade view bound.");
return;
}
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[M4] secure trade: LayoutDesc 0x2100000D root 0x1000007A not found.");
return;
}
var bus = _bindings.Options.CommandBus();
Layout.SecureTradeUiController? controller =
Layout.SecureTradeUiController.Bind(
layout,
new Layout.SecureTradeUiController.Bindings(
Trade: tradeView,
Objects: _bindings.Inventory.Objects,
ResolveIcon: _bindings.Inventory.ResolveIcon,
OpenTrade: partner => bus.Publish(
new OpenTradeNegotiationsRuntimeCmd(partner)),
CloseTrade: () => bus.Publish(
new CloseTradeNegotiationsRuntimeCmd()),
AddToTrade: item => bus.Publish(
new AddToTradeRuntimeCmd(item)),
AcceptTrade: (selfAccepted, partnerAccepted, partner) =>
bus.Publish(new AcceptTradeRuntimeCmd(
partner, selfAccepted, partnerAccepted)),
DeclineTrade: () => bus.Publish(new DeclineTradeRuntimeCmd()),
ResetTrade: () => bus.Publish(new ResetTradeRuntimeCmd()),
SetWindowVisible: visible =>
{
if (visible) Host.ShowWindow(WindowNames.SecureTrade);
else Host.HideWindow(WindowNames.SecureTrade);
}));
if (controller is null)
{
Console.WriteLine("[M4] secure trade: required authored grids are missing.");
return;
}
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.SecureTrade,
// Root 0x1000007A authors content only (the in-screen copy's
// bevel lives in LayoutDesc 0x21000005, not here) — the
// shared nine-slice frame surrounds it, same as the vendor.
Chrome = RetailWindowChrome.NineSlice,
Left = MathF.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = MathF.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
ContentWidth = root.Width,
ContentHeight = root.Height,
MinWidth = root.Width,
MinHeight = root.Height,
Visible = false,
ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
});
SecureTradeController = controller;
Host.WindowManager.AttachController(WindowNames.SecureTrade, controller);
// Both retail open paths (Use-on-player, drag-item-on-player) —
// raised by ItemInteractionController's policy execution arms.
_bindings.Inventory.ItemInteraction.SecureTradeRequested +=
controller.RequestSecureTrade;
Console.WriteLine(
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
@ -3506,6 +3607,11 @@ public sealed class RetailUiRuntime : IDisposable
_characterSheetSubscription?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowOpacity.Dispose();
if (SecureTradeController is { } trade)
{
_bindings.Inventory.ItemInteraction.SecureTradeRequested -=
trade.RequestSecureTrade;
}
},
() => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(),

View file

@ -27,6 +27,7 @@ public static class WindowNames
public const string Vitae = "vitae";
public const string Examination = "examination";
public const string Vendor = "vendor";
public const string SecureTrade = "secure-trade";
public const string Options = "options";
public const string KeyboardConfig = "keyboard-config";

View file

@ -112,7 +112,19 @@ public static class GameEventWiring
Action<ClientCommandResponses.AllegianceUpdate>? onAllegianceUpdate = null,
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null)
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null,
// Secure trade (2026-08-14): the same Runtime-owned delegate-hole
// shape as fellowship above. RuntimeTradeState is the consumer;
// docs/research/2026-08-14-trade-laneB-wire.md is the wire SSOT.
Action<GameEvents.RegisterTrade>? onTradeRegister = null,
Action<uint /*endReason*/>? onTradeClose = null,
Action<GameEvents.AddToTrade>? onTradeAdd = null,
Action<GameEvents.RemoveFromTrade>? onTradeRemove = null,
Action<uint /*whoAccepted*/>? onTradeAccept = null,
Action<uint /*whoDeclined*/>? onTradeDecline = null,
Action<uint /*whoReset*/>? onTradeReset = null,
Action<GameEvents.TradeFailure>? onTradeFailure = null,
Action? onTradeClearAcceptance = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -316,6 +328,80 @@ public static class GameEventWiring
});
}
// ── Secure trade (0x01FD0x0208) ──────────────────────────
if (onTradeRegister is not null)
{
registrar.Register(GameEventType.RegisterTrade, e =>
{
var p = GameEvents.ParseRegisterTrade(e.Payload.Span);
if (p is not null) onTradeRegister(p.Value);
});
}
if (onTradeClose is not null)
{
registrar.Register(GameEventType.CloseTrade, e =>
{
var p = GameEvents.ParseCloseTrade(e.Payload.Span);
if (p is not null) onTradeClose(p.Value);
});
}
if (onTradeAdd is not null)
{
registrar.Register(GameEventType.AddToTrade, e =>
{
var p = GameEvents.ParseAddToTrade(e.Payload.Span);
if (p is not null) onTradeAdd(p.Value);
});
}
if (onTradeRemove is not null)
{
// ACE never emits 0x0201; registered defensively for the retail
// handler's sake (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00).
registrar.Register(GameEventType.RemoveFromTrade, e =>
{
var p = GameEvents.ParseRemoveFromTrade(e.Payload.Span);
if (p is not null) onTradeRemove(p.Value);
});
}
if (onTradeAccept is not null)
{
registrar.Register(GameEventType.AcceptTrade, e =>
{
var p = GameEvents.ParseAcceptTrade(e.Payload.Span);
if (p is not null) onTradeAccept(p.Value);
});
}
if (onTradeDecline is not null)
{
registrar.Register(GameEventType.DeclineTrade, e =>
{
var p = GameEvents.ParseDeclineTrade(e.Payload.Span);
if (p is not null) onTradeDecline(p.Value);
});
}
if (onTradeReset is not null)
{
registrar.Register(GameEventType.ResetTrade, e =>
{
var p = GameEvents.ParseResetTrade(e.Payload.Span);
if (p is not null) onTradeReset(p.Value);
});
}
if (onTradeFailure is not null)
{
registrar.Register(GameEventType.TradeFailure, e =>
{
var p = GameEvents.ParseTradeFailure(e.Payload.Span);
if (p is not null) onTradeFailure(p.Value);
});
}
if (onTradeClearAcceptance is not null)
{
// 0x0208 carries no payload (GameEventClearTradeAcceptance).
registrar.Register(GameEventType.ClearTradeAcceptance, _ =>
onTradeClearAcceptance());
}
if (onConfirmationRequest is not null)
{
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>

View file

@ -462,31 +462,98 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0207 TradeFailure: server trade error code.</summary>
public static uint? ParseTradeFailure(ReadOnlySpan<byte> payload)
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
// ACE writers + retail parsers agree on every field below; retail
// dispatch addresses cited per event.
/// <summary>0x01FD RegisterTrade: (initiator, partner, stamp). Retail
/// <c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>. ACE landmine:
/// BOTH sides receive initiator == partner == the non-self player's guid
/// (never the true initiator) and stamp is always 0 — consumers must
/// derive "who opened" themselves (lane B §quirks).</summary>
public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp);
public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 16) return null;
return new RegisterTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8)));
}
/// <summary>0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2,
/// Canceled=0x51). Retail dispatch @ 0x006ACE90.</summary>
public static uint? ParseCloseTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0200 AddToTrade: (itemGuid, slotIndex).</summary>
public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex);
/// <summary>0x0200 AddToTrade: (itemGuid, side, slot). Side: 1 = the
/// receiving client's own offer, 2 = the partner's. Slot is always 0
/// from ACE. Retail dispatch @ 0x006ACE20 reads three dwords.</summary>
public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex);
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 12) return null;
return new AddToTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
}
/// <summary>0x0201 RemoveFromTrade: (itemGuid, mode). Retail
/// <c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>; ACE never
/// emits it (no per-item removal server-side) — parsed defensively.</summary>
public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode);
public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new RemoveFromTrade(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>0x0202 AcceptTrade: initiator guid.</summary>
/// <summary>0x0202 AcceptTrade: who accepted (the client compares
/// against its own guid for self-vs-partner — retail dispatch
/// @ 0x006ACDF0).</summary>
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0203 DeclineTrade: who declined.</summary>
public static uint? ParseDeclineTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0205 ResetTrade: who reset. ACE clears BOTH sides'
/// staged items on either player's reset (lane B §quirks).</summary>
public static uint? ParseResetTrade(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail
/// <c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c> removes the item
/// locally before showing the notice.</summary>
public readonly record struct TradeFailure(uint ItemGuid, uint Reason);
public static TradeFailure? ParseTradeFailure(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new TradeFailure(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>

View file

@ -0,0 +1,110 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Secure-trade GameAction builders — retail's <c>CM_Trade</c> senders,
/// byte-checked against ACE's readers (the server acdream runs against).
/// </summary>
/// <remarks>
/// Wire research: <c>docs/research/2026-08-14-trade-laneB-wire.md</c> —
/// ACE, the retail decomp, and holtburger's independent Rust implementation
/// agree on every field ACE implements. Retail senders:
/// <c>Event_OpenTradeNegotiations @ 0x0056D300</c>,
/// <c>Event_CloseTradeNegotiations @ 0x0056D1E0</c>,
/// <c>Event_AddToTrade @ 0x0056D0D0</c>, <c>Event_AcceptTrade</c> (packing
/// <c>Trade::Pack @ 0x005B9FF0</c>), <c>Event_DeclineTrade @ 0x0056D270</c>,
/// <c>Event_ResetTrade @ 0x0056D3D0</c>. There is NO RemoveFromTrade
/// C→S action — retail's per-item removal is client-local; ACE's only
/// clear is the full-window ResetTrade (which clears BOTH sides).
/// </remarks>
public static class TradeRequests
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint OpenTradeNegotiationsOpcode = 0x01F6u;
public const uint CloseTradeNegotiationsOpcode = 0x01F7u;
public const uint AddToTradeOpcode = 0x01F8u;
public const uint AcceptTradeOpcode = 0x01FAu;
public const uint DeclineTradeOpcode = 0x01FBu;
public const uint ResetTradeOpcode = 0x0204u;
/// <summary>Open trade with <paramref name="partnerGuid"/> — ACE walks
/// the initiator into range (<c>CreateMoveToChain</c>) before both sides
/// receive RegisterTrade.</summary>
public static byte[] BuildOpenTradeNegotiations(
uint gameActionSequence, uint partnerGuid)
{
byte[] body = new byte[16];
WriteHeader(body, gameActionSequence, OpenTradeNegotiationsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
return body;
}
public static byte[] BuildCloseTradeNegotiations(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, CloseTradeNegotiationsOpcode);
/// <summary>Stage an item. <paramref name="tradeSlot"/> is ACE-ignored
/// (it always echoes slot 0); retail sends its grid slot.</summary>
public static byte[] BuildAddToTrade(
uint gameActionSequence, uint itemGuid, uint tradeSlot = 0u)
{
byte[] body = new byte[20];
WriteHeader(body, gameActionSequence, AddToTradeOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), tradeSlot);
return body;
}
/// <summary>
/// Accept the current offer. The payload mirrors retail's
/// <c>Trade::Pack</c> fixed fields; ACE parses and then DISCARDS every
/// one of them (<c>HandleActionAcceptTrade()</c> takes zero arguments —
/// server state is fully self-derived), so the two trailing
/// PackableList&lt;ContentProfile&gt; item lists retail appends are sent
/// as zero-count lists here (register row AD-94).
/// </summary>
public static byte[] BuildAcceptTrade(
uint gameActionSequence,
uint partnerGuid,
double tradeStamp,
uint tradeStatus,
uint initiatorGuid,
bool initiatorAccepts,
bool partnerAccepts)
{
byte[] body = new byte[48];
WriteHeader(body, gameActionSequence, AcceptTradeOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
BinaryPrimitives.WriteDoubleLittleEndian(body.AsSpan(16), tradeStamp);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), tradeStatus);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(28), initiatorGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(32), initiatorAccepts ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(36), partnerAccepts ? 1u : 0u);
// Two zero-count item lists (see remarks / AD-94).
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(40), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(44), 0u);
return body;
}
public static byte[] BuildDeclineTrade(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, DeclineTradeOpcode);
/// <summary>Clear the trade window. ACE clears BOTH players' staged
/// items, not just the sender's (Player_Trade landmine — lane B §quirks).</summary>
public static byte[] BuildResetTrade(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, ResetTradeOpcode);
private static byte[] BuildEmpty(uint gameActionSequence, uint opcode)
{
byte[] body = new byte[12];
WriteHeader(body, gameActionSequence, opcode);
return body;
}
private static void WriteHeader(byte[] body, uint sequence, uint opcode)
{
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
}
}

View file

@ -2561,6 +2561,65 @@ public sealed class WorldSession : IDisposable
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
}
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
/// <summary>Open secure trade with another player — retail
/// <c>CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300</c>.</summary>
public void SendOpenTradeNegotiations(uint partnerGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid));
}
/// <summary>Close the trade window — <c>Event_CloseTradeNegotiations
/// @ 0x0056D1E0</c>.</summary>
public void SendCloseTradeNegotiations()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq));
}
/// <summary>Stage an item into the trade — <c>Event_AddToTrade
/// @ 0x0056D0D0</c>.</summary>
public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot));
}
/// <summary>Accept the current offer — <c>Event_AcceptTrade</c> packing
/// <c>Trade::Pack @ 0x005B9FF0</c>'s fixed fields (ACE discards the
/// payload entirely; see TradeRequests.BuildAcceptTrade).</summary>
public void SendAcceptTrade(
uint partnerGuid,
double tradeStamp,
uint tradeStatus,
uint initiatorGuid,
bool initiatorAccepts,
bool partnerAccepts)
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildAcceptTrade(
seq, partnerGuid, tradeStamp, tradeStatus,
initiatorGuid, initiatorAccepts, partnerAccepts));
}
/// <summary>Withdraw a previous accept — <c>Event_DeclineTrade
/// @ 0x0056D270</c>.</summary>
public void SendDeclineTrade()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildDeclineTrade(seq));
}
/// <summary>Clear the trade window — <c>Event_ResetTrade @ 0x0056D3D0</c>.
/// ACE clears BOTH sides' staged items (lane B §quirks).</summary>
public void SendResetTrade()
{
uint seq = NextGameActionSequence();
SendGameAction(TradeRequests.BuildResetTrade(seq));
}
/// <summary>
/// Send retail GiveObjectRequest (0x00CD). Retail
/// <c>CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00</c> writes

View file

@ -37,8 +37,10 @@ public enum GameRuntimeTeardownStage
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
FellowshipDisposed = 1 << 9,
AllegianceDisposed = 1 << 10,
IdentityDisposed = 1 << 11,
EntityObjectsDisposed = 1 << 12,
// Secure trade (2026-08-14): third sibling J-owner, same shape.
TradeDisposed = 1 << 11,
IdentityDisposed = 1 << 12,
EntityObjectsDisposed = 1 << 13,
Complete =
HostLeasesReleased
| EventsDetached
@ -51,6 +53,7 @@ public enum GameRuntimeTeardownStage
| CommunicationDisposed
| FellowshipDisposed
| AllegianceDisposed
| TradeDisposed
| IdentityDisposed
| EntityObjectsDisposed,
}
@ -94,6 +97,7 @@ internal enum GameRuntimeConstructionPoint
CommunicationCreated,
FellowshipCreated,
AllegianceCreated,
TradeCreated,
MovementCreated,
ActionsCreated,
EnvironmentCreated,
@ -111,6 +115,7 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeCommunicationState? Communication { get; set; }
public RuntimeFellowshipState? Fellowship { get; set; }
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeTradeState? Trade { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
public GameRuntimeEventHub? Events { get; set; }
@ -126,7 +131,7 @@ public sealed class GameRuntime
IRuntimeEventSource,
IDisposable
{
private const int TeardownStageCount = 13;
private const int TeardownStageCount = 14;
private readonly object _lifetimeGate = new();
private readonly Dictionary<long, string> _hostLeases = [];
@ -248,6 +253,16 @@ public sealed class GameRuntime
context,
faultInjection);
// Secure trade (2026-08-14): third sibling J-owner —
// session-scoped like fellowship (a disconnect closes the trade
// server-side), clears at every generation reset.
context.Trade = new RuntimeTradeState();
construction.Own(context.Trade);
Fault(
GameRuntimeConstructionPoint.TradeCreated,
context,
faultInjection);
context.Movement = new RuntimeLocalPlayerMovementState();
// Campaign CH slice CH2: local jump refusals (CommenceJump/
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
@ -302,7 +317,8 @@ public sealed class GameRuntime
context.Character,
context.PlayerIdentity,
context.Fellowship,
context.Allegiance);
context.Allegiance,
context.Trade);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@ -357,6 +373,7 @@ public sealed class GameRuntime
CommunicationOwner = context.Communication;
FellowshipOwner = context.Fellowship;
AllegianceOwner = context.Allegiance;
TradeOwner = context.Trade;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
EnvironmentOwner = environment;
@ -462,6 +479,9 @@ public sealed class GameRuntime
public RuntimeCommunicationState CommunicationOwner { get; }
public RuntimeFellowshipState FellowshipOwner { get; }
public RuntimeAllegianceState AllegianceOwner { get; }
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
public RuntimeTradeState TradeOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
internal RuntimeLocalPlayerPhysicsPublicationState
@ -507,6 +527,8 @@ public sealed class GameRuntime
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
public IRuntimeTradeView Trade => TradeOwner.View;
public IRuntimeActionView Actions => ActionOwner.View;
public IRuntimeMovementView Movement => MovementOwner.View;
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
@ -607,7 +629,8 @@ public sealed class GameRuntime
ActionOwner,
MovementOwner,
FellowshipOwner,
AllegianceOwner),
AllegianceOwner,
TradeOwner),
EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(),
GenerationReset.CaptureSnapshot(),
@ -733,16 +756,22 @@ public sealed class GameRuntime
9 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.FellowshipDisposed
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
11 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.TradeDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
12 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
13 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
_ => GameRuntimeTeardownStage.Complete,
};
@ -794,9 +823,12 @@ public sealed class GameRuntime
AllegianceOwner.Dispose();
return AllegianceOwner.CaptureOwnership().IsConverged;
case 11:
TradeOwner.Dispose();
return TradeOwner.CaptureOwnership().IsConverged;
case 12:
PlayerIdentity.Dispose();
return PlayerIdentity.CaptureOwnership().IsConverged;
case 12:
case 13:
EntityObjects.Dispose();
return EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
@ -819,8 +851,9 @@ public sealed class GameRuntime
8 => CommunicationOwner.CaptureOwnership().IsConverged,
9 => FellowshipOwner.CaptureOwnership().IsConverged,
10 => AllegianceOwner.CaptureOwnership().IsConverged,
11 => PlayerIdentity.CaptureOwnership().IsConverged,
12 => EntityObjects.CaptureOwnership().IsConverged
11 => TradeOwner.CaptureOwnership().IsConverged,
12 => PlayerIdentity.CaptureOwnership().IsConverged,
13 => EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
_ => true,
};

View file

@ -11,9 +11,11 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
RuntimeCommunicationOwnershipSnapshot Communication,
RuntimeActionOwnershipSnapshot Actions,
RuntimeLocalMovementOwnershipSnapshot Movement,
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners;
// secure trade (2026-08-14) is the third.
RuntimeFellowshipOwnershipSnapshot Fellowship,
RuntimeAllegianceOwnershipSnapshot Allegiance)
RuntimeAllegianceOwnershipSnapshot Allegiance,
RuntimeTradeOwnershipSnapshot Trade)
{
public bool IsConverged =>
Inventory.IsConverged
@ -22,7 +24,8 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
&& Actions.IsConverged
&& Movement.IsConverged
&& Fellowship.IsConverged
&& Allegiance.IsConverged;
&& Allegiance.IsConverged
&& Trade.IsConverged;
}
public static class RuntimeGameplayOwnership
@ -34,7 +37,8 @@ public static class RuntimeGameplayOwnership
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
@ -43,6 +47,7 @@ public static class RuntimeGameplayOwnership
ArgumentNullException.ThrowIfNull(movement);
ArgumentNullException.ThrowIfNull(fellowship);
ArgumentNullException.ThrowIfNull(allegiance);
ArgumentNullException.ThrowIfNull(trade);
return new RuntimeGameplayOwnershipSnapshot(
inventory.CaptureOwnership(),
character.CaptureOwnership(),
@ -50,6 +55,7 @@ public static class RuntimeGameplayOwnership
actions.CaptureOwnership(),
movement.CaptureOwnership(),
fellowship.CaptureOwnership(),
allegiance.CaptureOwnership());
allegiance.CaptureOwnership(),
trade.CaptureOwnership());
}
}

View file

@ -0,0 +1,307 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeTradeOwnershipSnapshot(
bool IsDisposed,
bool IsOpen,
int StagedItemCount)
{
public bool IsConverged =>
IsDisposed
&& !IsOpen
&& StagedItemCount == 0;
}
/// <summary>One player's staged-item side of the trade window.</summary>
public enum RuntimeTradeSide : uint
{
Self = 1u,
Partner = 2u,
}
/// <summary>Immutable poll snapshot of the whole trade.</summary>
public readonly record struct RuntimeTradeSnapshot(
long Revision,
bool IsOpen,
uint PartnerGuid,
bool SelfAccepted,
bool PartnerAccepted,
int SelfItemCount,
int PartnerItemCount,
uint LastFailureItemGuid,
uint LastFailureReason);
public interface IRuntimeTradeView
{
RuntimeTradeSnapshot Snapshot { get; }
/// <summary>Materialized staged-item guids for one side, in stage order.</summary>
IReadOnlyList<uint> GetItems(RuntimeTradeSide side);
}
/// <summary>
/// Canonical presentation-independent owner for the secure-trade window —
/// retail's <c>ClientTradeSystem</c>/<c>Trade</c> state, session-scoped like
/// <see cref="RuntimeFellowshipState"/> (a disconnect closes the trade
/// server-side, so this clears at every generation reset).
/// </summary>
/// <remarks>
/// Wire SSOT: <c>docs/research/2026-08-14-trade-laneB-wire.md</c>; retail UI
/// truth: <c>docs/research/2026-08-14-trade-laneA-ui.md</c>. Assembled from
/// the 0x01FD0x0208 event family. ACE landmines honored here:
/// RegisterTrade's initiator/partner fields BOTH carry the non-self player's
/// guid (the true initiator is never on the wire — <c>Player_Trade.cs:80,98</c>),
/// so <see cref="ApplyRegister"/> derives the partner as "whichever guid is
/// not mine, else either"; ResetTrade clears BOTH sides' items regardless of
/// who reset. Consumers poll <see cref="View"/>'s monotonic revision — no
/// push event, the same D2 discipline as fellowship.
/// </remarks>
public sealed class RuntimeTradeState : IDisposable
{
private readonly object _gate = new();
private readonly List<uint> _selfItems = [];
private readonly List<uint> _partnerItems = [];
private bool _isOpen;
private uint _partnerGuid;
private bool _selfAccepted;
private bool _partnerAccepted;
private uint _lastFailureItemGuid;
private uint _lastFailureReason;
private long _revision;
private bool _disposed;
public RuntimeTradeState() => View = new TradeView(this);
public IRuntimeTradeView View { get; }
public bool IsDisposed
{
get { lock (_gate) return _disposed; }
}
/// <summary>
/// 0x01FD RegisterTrade — the window opens on BOTH clients
/// (<c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>). Clears any
/// stale staged state from a previous trade.
/// </summary>
public void ApplyRegister(GameEvents.RegisterTrade update, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_isOpen = true;
_partnerGuid = update.Initiator != selfGuid && update.Initiator != 0u
? update.Initiator
: update.Partner;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
_lastFailureItemGuid = 0u;
_lastFailureReason = 0u;
Bump();
}
}
/// <summary>0x01FF CloseTrade — full teardown on either side's close
/// (reason recorded nowhere; retail closes the panel outright).</summary>
public void ApplyClose()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ClearLocked();
}
}
/// <summary>0x0200 AddToTrade — Side 1 = this client's own offer echo,
/// Side 2 = the partner staged an item. ACE's slot is always 0; stage
/// order is arrival order (retail's grid does the same against ACE).</summary>
public void ApplyAdd(GameEvents.AddToTrade update)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
List<uint> items = update.Side == (uint)RuntimeTradeSide.Partner
? _partnerItems
: _selfItems;
if (!items.Contains(update.ItemGuid))
items.Add(update.ItemGuid);
// Staging changes invalidate prior acceptance server-side (ACE
// re-arms via ClearTradeAcceptance; mirrored defensively here so
// a dropped 0x0208 cannot leave a stale green check).
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>0x0201 RemoveFromTrade — ACE never emits it; honored
/// defensively for the retail handler's shape
/// (<c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>).</summary>
public void ApplyRemove(GameEvents.RemoveFromTrade update)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
bool removed = _selfItems.Remove(update.ItemGuid);
removed |= _partnerItems.Remove(update.ItemGuid);
if (removed) Bump();
}
}
/// <summary>0x0202 AcceptTrade — who accepted; compared against the
/// local player exactly like retail's dispatch @ 0x006ACDF0.</summary>
public void ApplyAccept(uint whoAccepted, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
if (whoAccepted == selfGuid) _selfAccepted = true;
else _partnerAccepted = true;
Bump();
}
}
/// <summary>0x0203 DeclineTrade — withdraws that side's acceptance.</summary>
public void ApplyDecline(uint whoDeclined, uint selfGuid)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
if (whoDeclined == selfGuid) _selfAccepted = false;
else _partnerAccepted = false;
Bump();
}
}
/// <summary>0x0205 ResetTrade — ACE clears BOTH sides' staged items on
/// either player's reset (lane B §quirks), and acceptance with them.
/// The window stays open (a completed trade auto-resets this way).</summary>
public void ApplyReset()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>0x0207 TradeFailure — retail removes the refused item
/// locally before showing the notice
/// (<c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c>).</summary>
public void ApplyFailure(GameEvents.TradeFailure failure)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfItems.Remove(failure.ItemGuid);
_partnerItems.Remove(failure.ItemGuid);
_lastFailureItemGuid = failure.ItemGuid;
_lastFailureReason = failure.Reason;
Bump();
}
}
/// <summary>0x0208 ClearTradeAcceptance — both checks come down.</summary>
public void ApplyClearAcceptance()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
_selfAccepted = false;
_partnerAccepted = false;
Bump();
}
}
/// <summary>Generation reset / session teardown — full clear.</summary>
public void Clear()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
}
}
public RuntimeTradeOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
return new RuntimeTradeOwnershipSnapshot(
_disposed,
_isOpen,
_selfItems.Count + _partnerItems.Count);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
ClearLocked();
_disposed = true;
}
}
private void ClearLocked()
{
bool changed = _isOpen
|| _selfItems.Count != 0
|| _partnerItems.Count != 0
|| _selfAccepted
|| _partnerAccepted;
_isOpen = false;
_partnerGuid = 0u;
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
_partnerAccepted = false;
_lastFailureItemGuid = 0u;
_lastFailureReason = 0u;
if (changed) Bump();
}
private void Bump() => _revision++;
private sealed class TradeView(RuntimeTradeState owner) : IRuntimeTradeView
{
public RuntimeTradeSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeTradeSnapshot(
owner._revision,
owner._isOpen,
owner._partnerGuid,
owner._selfAccepted,
owner._partnerAccepted,
owner._selfItems.Count,
owner._partnerItems.Count,
owner._lastFailureItemGuid,
owner._lastFailureReason);
}
}
public IReadOnlyList<uint> GetItems(RuntimeTradeSide side)
{
lock (owner._gate)
return side == RuntimeTradeSide.Partner
? [.. owner._partnerItems]
: [.. owner._selfItems];
}
}
}

View file

@ -53,15 +53,22 @@ public enum RuntimeGenerationResetStage
/// from the precedent it named).
/// </summary>
Allegiance = 13,
BeginEntityRetirement = 14,
RetireEntities = 15,
DrainHostProjection = 16,
CompleteCanonicalEntities = 17,
CompleteHostProjection = 18,
ChatIdentity = 19,
PlayerSnapshots = 20,
PlayerIdentity = 21,
Complete = 22,
/// <summary>
/// Secure trade (2026-08-14): the trade window is session-scoped — a
/// disconnect closes the trade server-side (ACE tears the negotiation
/// down with the session), so the third sibling J-owner clears here
/// beside its fellowship/allegiance precedents.
/// </summary>
Trade = 14,
BeginEntityRetirement = 15,
RetireEntities = 16,
DrainHostProjection = 17,
CompleteCanonicalEntities = 18,
CompleteHostProjection = 19,
ChatIdentity = 20,
PlayerSnapshots = 21,
PlayerIdentity = 22,
Complete = 23,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -111,6 +118,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeLocalPlayerIdentityState _identity;
private readonly RuntimeFellowshipState _fellowship;
private readonly RuntimeAllegianceState _allegiance;
private readonly RuntimeTradeState _trade;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
@ -127,7 +135,8 @@ public sealed class RuntimeGenerationReset
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -147,6 +156,7 @@ public sealed class RuntimeGenerationReset
?? throw new ArgumentNullException(nameof(fellowship));
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -320,6 +330,9 @@ public sealed class RuntimeGenerationReset
case RuntimeGenerationResetStage.Allegiance:
Advance(state, _allegiance.ResetSession);
break;
case RuntimeGenerationResetStage.Trade:
Advance(state, _trade.Clear);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects

View file

@ -30,7 +30,8 @@ public static class RuntimeSimulationOwnership
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
RuntimeAllegianceState allegiance,
RuntimeTradeState trade)
{
ArgumentNullException.ThrowIfNull(entityObjects);
return new RuntimeSimulationOwnershipSnapshot(
@ -43,6 +44,7 @@ public static class RuntimeSimulationOwnership
actions,
movement,
fellowship,
allegiance));
allegiance,
trade));
}
}

View file

@ -85,7 +85,10 @@ public sealed record LiveSocialSessionBindings(
// compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md
// §2.4 — "the established compatibility convention").
RuntimeFellowshipState? Fellowship = null,
RuntimeAllegianceState? Allegiance = null);
RuntimeAllegianceState? Allegiance = null,
// Secure trade (2026-08-14): the third sibling J-owner, same
// trailing/optional compatibility convention.
RuntimeTradeState? Trade = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -281,6 +284,36 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
? notice => allegianceLogin.ApplyLoginNotification(
notice.CharacterGuid,
notice.IsLoggedIn)
: null,
// Secure trade (2026-08-14): same conditional delegate-hole
// discipline. Self-vs-partner comparisons use the exact
// player guid the fellowship holes above already borrow.
onTradeRegister: social.Trade is { } tradeRegister
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
: null,
onTradeClose: social.Trade is { } tradeClose
? _ => tradeClose.ApplyClose()
: null,
onTradeAdd: social.Trade is { } tradeAdd
? tradeAdd.ApplyAdd
: null,
onTradeRemove: social.Trade is { } tradeRemove
? tradeRemove.ApplyRemove
: null,
onTradeAccept: social.Trade is { } tradeAccept
? whoAccepted => tradeAccept.ApplyAccept(whoAccepted, inventory.PlayerGuid())
: null,
onTradeDecline: social.Trade is { } tradeDecline
? whoDeclined => tradeDecline.ApplyDecline(whoDeclined, inventory.PlayerGuid())
: null,
onTradeReset: social.Trade is { } tradeReset
? _ => tradeReset.ApplyReset()
: null,
onTradeFailure: social.Trade is { } tradeFailure
? tradeFailure.ApplyFailure
: null,
onTradeClearAcceptance: social.Trade is { } tradeClear
? tradeClear.ApplyClearAcceptance
: null));
ConstructionCheckpoint();

View file

@ -71,6 +71,38 @@ public sealed class PowerbarLayoutProbeTests
}
}
/// <summary>2026-08-14 trade research: locate the LayoutDesc that authors
/// gmSecureTradeUI's element tree (lane A gave element ids 0x10000085-8B
/// but not the layout id) and dump it.</summary>
[Fact]
public void ProbeSecureTradeLayout()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1")
return;
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var strings = new DatStringResolver(dats);
int scanned = 0;
for (uint layoutId = 0x21000000u; layoutId <= 0x210001FFu; layoutId++)
{
ElementInfo? root = LayoutImporter.ImportInfos(dats, layoutId);
if (root is null)
continue;
scanned++;
if (FindById(root, 0x10000088u) is null)
continue;
Console.WriteLine($"[pbprobe] SECURE TRADE LAYOUT = 0x{layoutId:X8}");
DumpElement(strings, root, 0);
}
Console.WriteLine($"[pbprobe] scanned {scanned} layouts");
}
private static ElementInfo? FindById(ElementInfo element, uint id)
{
if (element.Id == id) return element;

View file

@ -0,0 +1,143 @@
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte coverage for the secure-trade GameAction builders and inbound
/// parsers — the 2026-08-14 lane B wire tables (ACE + retail decomp +
/// holtburger three-way agreement).
/// </summary>
public sealed class TradeRequestsTests
{
[Fact]
public void BuildOpenTradeNegotiations_WritesEnvelopeSequenceOpcodePartner()
{
byte[] body = TradeRequests.BuildOpenTradeNegotiations(7, 0x50001234u);
Assert.Equal(16, body.Length);
Assert.Equal(TradeRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(0x01F6u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x50001234u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Theory]
[InlineData(0x01F7u)] // CloseTradeNegotiations
[InlineData(0x01FBu)] // DeclineTrade
[InlineData(0x0204u)] // ResetTrade
public void EmptyBodiedActions_WriteEnvelopeSequenceOpcodeOnly(uint opcode)
{
byte[] body = opcode switch
{
0x01F7u => TradeRequests.BuildCloseTradeNegotiations(3),
0x01FBu => TradeRequests.BuildDeclineTrade(3),
_ => TradeRequests.BuildResetTrade(3),
};
Assert.Equal(12, body.Length);
Assert.Equal(TradeRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void BuildAddToTrade_WritesItemAndSlot()
{
byte[] body = TradeRequests.BuildAddToTrade(5, 0x60000001u, 2u);
Assert.Equal(20, body.Length);
Assert.Equal(0x01F8u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x60000001u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
}
[Fact]
public void BuildAcceptTrade_WritesTradePackFixedFieldsAndTwoEmptyLists()
{
byte[] body = TradeRequests.BuildAcceptTrade(
gameActionSequence: 11,
partnerGuid: 0x50000B0Bu,
tradeStamp: 0d,
tradeStatus: 0u,
initiatorGuid: 0x5000A0A0u,
initiatorAccepts: true,
partnerAccepts: false);
Assert.Equal(48, body.Length);
Assert.Equal(0x01FAu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x50000B0Bu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(0d, BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(16)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
Assert.Equal(0x5000A0A0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28)));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(36)));
// Two zero-count PackableLists (AD-94 — ACE discards the payload).
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(40)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(44)));
}
// ── Inbound parsers ─────────────────────────────────────────────────────
[Fact]
public void ParseRegisterTrade_ReadsInitiatorPartnerStamp()
{
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x50000002u);
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(8), 0uL);
var parsed = GameEvents.ParseRegisterTrade(payload);
Assert.NotNull(parsed);
Assert.Equal(0x50000001u, parsed!.Value.Initiator);
Assert.Equal(0x50000002u, parsed.Value.Partner);
Assert.Equal(0uL, parsed.Value.Stamp);
Assert.Null(GameEvents.ParseRegisterTrade(payload.AsSpan(0, 12)));
}
[Fact]
public void ParseAddToTrade_ReadsGuidSideSlot()
{
byte[] payload = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000009u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u);
var parsed = GameEvents.ParseAddToTrade(payload);
Assert.NotNull(parsed);
Assert.Equal(0x60000009u, parsed!.Value.ItemGuid);
Assert.Equal(2u, parsed.Value.Side);
Assert.Equal(0u, parsed.Value.SlotIndex);
Assert.Null(GameEvents.ParseAddToTrade(payload.AsSpan(0, 8)));
}
[Fact]
public void ParseTradeFailure_ReadsGuidAndReason()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000042u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x426u);
var parsed = GameEvents.ParseTradeFailure(payload);
Assert.NotNull(parsed);
Assert.Equal(0x60000042u, parsed!.Value.ItemGuid);
Assert.Equal(0x426u, parsed.Value.Reason);
Assert.Null(GameEvents.ParseTradeFailure(payload.AsSpan(0, 4)));
}
[Fact]
public void SingleGuidParsers_ReadTheGuid()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000C0Cu);
Assert.Equal(0x50000C0Cu, GameEvents.ParseAcceptTrade(payload));
Assert.Equal(0x50000C0Cu, GameEvents.ParseDeclineTrade(payload));
Assert.Equal(0x50000C0Cu, GameEvents.ParseResetTrade(payload));
Assert.Equal(0x50000C0Cu, GameEvents.ParseCloseTrade(payload));
}
}

View file

@ -239,6 +239,7 @@ public sealed class GameRuntimeTests
GameRuntimeTeardownStage.CommunicationDisposed,
GameRuntimeTeardownStage.FellowshipDisposed,
GameRuntimeTeardownStage.AllegianceDisposed,
GameRuntimeTeardownStage.TradeDisposed,
GameRuntimeTeardownStage.IdentityDisposed,
GameRuntimeTeardownStage.EntityObjectsDisposed,
];

View file

@ -20,6 +20,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Transactions.IncrementBusyCount();
@ -38,7 +39,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.False(populated.IsConverged);
movement.Dispose();
@ -48,6 +50,7 @@ public sealed class RuntimeGameplayOwnershipTests
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
entities.Dispose();
RuntimeSimulationOwnershipSnapshot retired =
@ -59,7 +62,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.True(retired.EntityObjects.IsDisposed);
Assert.True(retired.Physics.IsDisposed);
@ -76,6 +80,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
@ -139,7 +144,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount);
@ -161,6 +167,7 @@ public sealed class RuntimeGameplayOwnershipTests
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
subscription.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
@ -171,7 +178,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@ -191,6 +199,7 @@ public sealed class RuntimeGameplayOwnershipTests
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
var trade = new RuntimeTradeState();
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -215,6 +224,7 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Dispose();
fellowship.Dispose();
allegiance.Dispose();
trade.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
RuntimeGameplayOwnership.Capture(
@ -224,7 +234,8 @@ public sealed class RuntimeGameplayOwnershipTests
actions,
movement,
fellowship,
allegiance);
allegiance,
trade);
Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount);

View file

@ -0,0 +1,142 @@
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// Secure-trade owner conformance (2026-08-14): the 0x01FD0x0208 event
/// family against the lane B wire truths — including ACE's
/// wrong-initiator RegisterTrade, its both-sides ResetTrade, and retail's
/// remove-before-notice TradeFailure handling.
/// </summary>
public sealed class RuntimeTradeStateTests
{
private const uint Self = 0x50000001u;
private const uint Partner = 0x50000002u;
private const uint ItemA = 0x60000001u;
private const uint ItemB = 0x60000002u;
[Fact]
public void RegisterOpensAndDerivesPartnerDespiteAceGuidLandmine()
{
using var trade = new RuntimeTradeState();
// ACE sends initiator == partner == the non-self guid on BOTH sides.
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.True(snapshot.IsOpen);
Assert.Equal(Partner, snapshot.PartnerGuid);
Assert.False(snapshot.SelfAccepted);
Assert.False(snapshot.PartnerAccepted);
}
[Fact]
public void AddStagesPerSideAndDropsBothAcceptances()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAccept(Self, Self);
trade.ApplyAccept(Partner, Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
// Duplicate echo is a no-op stage-wise.
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.Equal(1, snapshot.SelfItemCount);
Assert.Equal(1, snapshot.PartnerItemCount);
Assert.Equal([ItemA], trade.View.GetItems(RuntimeTradeSide.Self));
Assert.Equal([ItemB], trade.View.GetItems(RuntimeTradeSide.Partner));
// Staging invalidates prior acceptance (the ClearTradeAcceptance
// mirror).
Assert.False(snapshot.SelfAccepted);
Assert.False(snapshot.PartnerAccepted);
}
[Fact]
public void AcceptDeclineTrackSelfVersusPartnerByGuid()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAccept(Partner, Self);
Assert.True(trade.View.Snapshot.PartnerAccepted);
Assert.False(trade.View.Snapshot.SelfAccepted);
trade.ApplyAccept(Self, Self);
Assert.True(trade.View.Snapshot.SelfAccepted);
trade.ApplyDecline(Partner, Self);
Assert.False(trade.View.Snapshot.PartnerAccepted);
Assert.True(trade.View.Snapshot.SelfAccepted);
}
[Fact]
public void ResetClearsBothSidesButKeepsTheWindowOpen()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
trade.ApplyAccept(Self, Self);
trade.ApplyReset();
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.True(snapshot.IsOpen);
Assert.Equal(0, snapshot.SelfItemCount);
Assert.Equal(0, snapshot.PartnerItemCount);
Assert.False(snapshot.SelfAccepted);
}
[Fact]
public void FailureRemovesTheItemAndRecordsTheReason()
{
using var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyFailure(new GameEvents.TradeFailure(ItemA, 0x426u));
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.Equal(0, snapshot.SelfItemCount);
Assert.Equal(ItemA, snapshot.LastFailureItemGuid);
Assert.Equal(0x426u, snapshot.LastFailureReason);
}
[Fact]
public void CloseAndGenerationClearConvergeTheLedger()
{
var trade = new RuntimeTradeState();
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyClose();
Assert.False(trade.View.Snapshot.IsOpen);
Assert.Equal(0, trade.View.Snapshot.SelfItemCount);
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
trade.Clear();
Assert.False(trade.View.Snapshot.IsOpen);
trade.Dispose();
Assert.True(trade.CaptureOwnership().IsConverged);
}
[Fact]
public void EventsBeforeRegisterAreIgnored()
{
using var trade = new RuntimeTradeState();
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
trade.ApplyAccept(Partner, Self);
trade.ApplyReset();
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
Assert.False(snapshot.IsOpen);
Assert.Equal(0, snapshot.SelfItemCount);
Assert.False(snapshot.PartnerAccepted);
}
}