# 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`, 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.