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

316 lines
21 KiB
Markdown

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