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>
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_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
- A → S:
OpenTradeNegotiations(0x01F6)targeting B's guid (Player_Trade.cs:30-100). - Server-side checks in order (
HandleActionOpenTradeNegotiations,initiator=truebranch,:32-83): A not Olthoi → B online → B not Olthoi → B notIgnoreAllTradeRequests→ neither alreadyIsTrading→ neither in combat mode → A moves/rotates to B viaCreateMoveToChain(this is the "if in range" distance gate — failure sendsWeenieError.TradeMaxDistanceExceeded, no trade starts). - On successful approach, S → A:
RegisterTrade(0x01FD)with(initiator=B.Guid, partner=B.Guid, 0L)— note ACE passestradePartner.Guidfor BOTH fields here (Player_Trade.cs:80), not(A.Guid, B.Guid); this looks like an ACE bug relative to retail's_partner/_initiatorsemantics, but it is what ships (flagged again in Quirks below). - 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) setsIsTrading=trueon both, clears bothItemsInTradeWindow, setsTradePartnercross-links, then: - 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 ownSession). - A → S:
AddToTrade(0x01F8)with(itemGuid, tradeSlot)for each item A drags into the window. Server (HandleActionAddToTrade,:116-175): rejects ifTradeTransferInProgress; 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 toA.ItemsInTradeWindow; S → A:AddToTrade(0x0200)(itemGuid, TradeSide.Self=1, 0)immediately; then after a 0.001sActionChaindelay, S → B:AddToTrade(0x0200)(itemGuid, TradeSide.Partner=2, 0). Same flow mirrored when B adds items (roles of Self/Partner flip per session). - A → S:
AcceptTrade(0x01FA)(fullTrade::Packpayload, but ACE only parses the header — no fields are used). Server (HandleActionAcceptTrade,:196-216): setsA.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"). IfB.TradeAcceptedis 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'sHandleActionAcceptTradethat findstarget.TradeAccepted==trueand callsFinalizeTrade). FinalizeTrade(:218-283), runs on whichever side's accept was second:VerifyTrade_BusyState— if either playerIsBusy, abort: both get aCommunicationTransientStringexplaining who's busy, andClearTradeAcceptancefires on both (S → both:ClearTradeAcceptance(0x0208), no fields — see step "Failure: busy / inventory" below).VerifyTrade_Inventory— re-resolves bothItemsInTradeWindowsets by guid (if any item vanished,HandleActionDeclineTradefires for that side instead); then checksCanAddToInventory(burden + free-slot capacity) for the INCOMING items on both sides. Failure → per-sideCommunicationTransientString(encumbered vs. no-free-slots wording)ClearTradeAcceptanceon both, trade stays open with items still in the window.
- On success:
IsBusy=trueon both,TradeTransferInProgress=trueon both; S → A and S → B:CommunicationTransientString("The items are being traded"). - Escrow: for every guid in
A.ItemsInTradeWindow,TryRemoveFromInventoryWithNetworking(..., RemoveFromInventoryAction.TradeItem)orTryDequipObjectWithNetworking(..., 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
ActionChaindelay: deliver each escrowed item to its new owner viaTryCreateInInventoryWithNetworking— emitsGameMessageCreateObject(item)(+GameEventViewContentsand childGameMessageCreateObjects 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)viaGameEventWeenieError. TradeTransferInProgress=false,IsBusy=falseon both;SaveBiotasInParallelpersists the moved items; thenHandleActionResetTraderuns for both sides (S → A, S → B:ResetTrade(0x0205)(whoReset=own guid)) — this clearsItemsInTradeWindow/TradeAcceptedbut leavesIsTrading/TradePartnerintact, 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'sCreateMoveToChaincallback failed (target moved away / unreachable) before anyRegisterTradeis sent. No trade session created. - Already trading:
WeenieError.TradeAlreadyTrading (0x044F)— either side alreadyIsTrading. - 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 hasCharacterOption.IgnoreAllTradeRequestsset. - Attuned/pet item:
AddToTraderefused —GameEventCommunicationTransientString("You cannot trade that!")(or the pet-specific string) +TradeFailure(0x0207)withreason=WeenieError.AttunedItem. Item never entersItemsInTradeWindow. - Unique-item cap:
AddToTraderefused whenwo.IsUniqueOrContainsUnique && !target.CheckUniques(...)—TradeFailure(0x0207)withreason=WeenieError.None(ACE leaves a// TODOcomment atPlayer_Trade.cs:156questioning whether this should beTooManyUniqueItemsor aWeenieErrorWithString— as shipped it's the genericNonereason, i.e. the client gets a failure with no readable cause). - Busy at finalize:
VerifyTrade_BusyStatefails —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_Inventoryfails (encumbrance or free-slot check viaCanAddToInventory) —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):
GetItemsInTradeWindowreturns false for that side insideVerifyTrade_Inventory, which routes intoHandleActionDeclineTradefor the affected side — same wire as the manual decline path (DeclineTrade(0x0203)+ transient string to both).
ACE quirks / landmines vs. retail
RegisterTradesends the wrong initiator guid. Both S→A and S→BRegisterTradeevents carry(initiator=tradePartner.Guid, partner=tradePartner.Guid)— i.e. the non-initiator's guid in both slots, always (Player_Trade.cs:80,:98). Retail'sTrade::Register(partnerGuid, stamp)(decomp0x005b9ef0) only takes a singlepartnerargument and separately tracks_initiatorelsewhere in theTradeobject, so the client is presumably readinginitiator/partnerfields that ACE fills identically and incorrectly. Whether any retail client logic actually branches onRegisterTrade'sinitiatorfield (vs. deriving initiator status locally) is unverified in this pass — flag before relying on that field client-side.OpenTrade (0x01FE)is never sent. Retail's dispatcher andHandle_Trade__Recv_OpenTradeexist and are wired (DispatchUI_Recv_OpenTrade@0x006acef0), but no ACEGameEvent*class emitsGameEventType.OpenTrade.RegisterTradeis what actually establishes the session; whatever retail UI behavior was gated on the separateOpenTradenotice (apartnerGuid-only payload) never fires against ACE.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, callingTrade::RemoveItem). ACE has no server-side action to remove a single item either — there is noGameActionTypebetweenAddToTrade (0x1F8)andAcceptTrade (0x1FA)reserved for it beyond the opcode gap at0x1F9. The only way to change an offer on ACE isResetTrade (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.AcceptTrade's client-echoed state is entirely ignored server-side. The client packs its full localTradesnapshot — partner guid, a double timestamp, status, initiator guid, both accept flags, AND the two variable-length item lists it believes are in play — butGameActionAcceptTrade.Handle(:11-18) reads all six fixed fields into locals and then callssession.Player.HandleActionAcceptTrade()with zero arguments; none of the parsed values are used. ACE derives accept state purely from its ownTradeAcceptedbool and the twoItemsInTradeWindowsets. This means a desynced client (stale localTradeobject) 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.RegisterTrade's stamp andAddToTrade's slot are hardcoded zero. ACE always writes0Lfor the trade timestamp (GameEventRegisterTrade.cs:12) and0for 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.- Distance/approach gate only applies to the initiator.
CreateMoveToChain(auto-walk-to-target) only runs in theinitiator=truebranch (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 atAddToTradeorAcceptTradetime — a trade session, once open, has no live proximity requirement to keep offering or accepting items even if the players walk apart afterward. 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).- Attuned/unique-item refusals leave the item untouched, no
RemoveFromTradeneeded — since the item is refused before ever being added toItemsInTradeWindow, there is nothing to roll back client-side beyond theTradeFailurenotice.