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>
316 lines
21 KiB
Markdown
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.
|