acdream/docs/research/2026-08-14-trade-laneB-wire.md
Erik 067cbea8a5 feat: secure trade with other players - wire, RuntimeTradeState, the
authored gmSecureTradeUI window, and both retail open paths

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:49:13 +02:00

21 KiB

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_acceptedacclient_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 GameMessageCreateObjects 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.