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>
This commit is contained in:
parent
bee38b0746
commit
067cbea8a5
26 changed files with 2682 additions and 42 deletions
File diff suppressed because one or more lines are too long
364
docs/research/2026-08-14-trade-laneA-ui.md
Normal file
364
docs/research/2026-08-14-trade-laneA-ui.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
# Retail secure-trade UI (`gmSecureTradeUI`) — decomp decode
|
||||
|
||||
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR
|
||||
build, PDB-named). All addresses are `acclient.exe` v11.4186 file offsets
|
||||
(module base ~`0x00400000`). The class is **`gmSecureTradeUI`**, not
|
||||
`gmTradeUI` — grep the correct name if re-deriving.
|
||||
|
||||
Element-type-id convention observed in this file (per `DynamicCast`/
|
||||
`GetUIElementType` pairs): `1`=Button, `3`(alias `0xa`? see ItemList note
|
||||
below)=Field-ish base, `0xc`=Text, and high `0x100000XX` values are
|
||||
per-class dynamic type ids assigned to more complex controls
|
||||
(`gmSecureTradeUI` itself is `0x10000012`, `UIElement_ItemList` is
|
||||
`0x10000031` (also answers to legacy id `5`), `UIElement_UIItem` (an
|
||||
item icon inside a list/paperdoll) is `0x10000032`).
|
||||
|
||||
## 1. Class shape: Create / Register / PostInit / ListenToElementMessage
|
||||
|
||||
| Method | Address | Notes |
|
||||
|---|---|---|
|
||||
| `gmSecureTradeUI::gmSecureTradeUI` (ctor) | `0x004c97c0` | Base-inits as `UIElement_Field`, then wires in `ObjectRangeHandler` and `ItemListDragHandler` interface vtables (multiple-inheritance vtable-slot assignment — the pseudo-C shows two of these assignments mislabeled as `&gmUrgentAssistanceUI::\`vftable'` / plain vtable-thunk addresses; **this is a known Binary-Ninja mislabeling artifact, not a real base-class relationship** — trust the interface list (`ObjectRangeHandler`, `ItemListDragHandler`), not the printed symbol name). Zeroes the button/list pointer block (`memset(&m_pTradeButton, 0, 0x28)`), inits `splitItemStackSize=0`, `splitItemClassID=INVALID_DID`. |
|
||||
| `gmSecureTradeUI::Create` | `0x004c9890` | `operator new(0x634)` (0x634-byte instance) → ctor. |
|
||||
| `gmSecureTradeUI::Register` | `0x004c9cc0` | `UIElement::RegisterElementClass(0x10000012, gmSecureTradeUI::Create)`. **Element class id = `0x10000012`.** |
|
||||
| `gmSecureTradeUI::DynamicCast` | `0x004c96d0` | Answers `0x10000012` (self) or `3`. |
|
||||
| `gmSecureTradeUI::GetUIElementType` | `0x004c96f0` | Returns `0x10000012`. |
|
||||
| `gmSecureTradeUI::PostInit` | `0x004ca160` | See binding table below. |
|
||||
| `gmSecureTradeUI::ListenToElementMessage` | `0x004cae80` | See click table below. |
|
||||
| `gmSecureTradeUI::~gmSecureTradeUI` / scalar-deleting-dtor | `0x004c9650` / `0x004c9870` | Nothing trade-specific. |
|
||||
|
||||
### PostInit element bindings (`GetChildRecursive` + `DynamicCast`)
|
||||
|
||||
All at `0x004ca160`. Order as they appear in code:
|
||||
|
||||
| Child element id | `DynamicCast` arg | Field | Meaning |
|
||||
|---|---|---|---|
|
||||
| `0x10000086` | `1` (Button) | `m_pTradeButton` | Combined Accept/Decline action button (see click table). |
|
||||
| `0x10000085` | `0xc` (Text) | `m_pSelfPlayerName` | Your own name label. |
|
||||
| `0x10000087` | `0xc` (Text) | `m_pSelfTotalItemsLabel` | "N items" label, your side. |
|
||||
| `0x10000088` | `0x10000031` (ItemList) | `m_pSelfItemsList` | **Your item grid.** Also calls `UIElement_ItemList::RegisterItemListDragHandler(eax_22, &this->vtable)` — `gmSecureTradeUI` is its own drag handler. |
|
||||
| `0x1000007f` | *(no cast — raw `UIElement*`)* | `m_pOtherTradeStatusIndicator` | Partner "has accepted" status icon/indicator. |
|
||||
| `0x1000007e` | `0xc` (Text) | `m_pOtherPlayerName` | Partner's name label. |
|
||||
| `0x10000080` | `0xc` (Text) | `m_pOtherTotalItemsLabel` | "N items" label, partner side. |
|
||||
| `0x10000081` | `0x10000031` (ItemList) | `m_pOtherItemsList` | **Partner's item grid** (no drag handler registered — you can't drag out of/into it). |
|
||||
| `0x1000008a` | `1` (Button) | `m_pClearAllItemsButton` | "Clear all" / Reset button — same id is also read directly in `ListenToElementMessage` (see below), so it's bound to a field *and* hard-checked by id on click. |
|
||||
|
||||
PostInit ends by tail-calling `gmSecureTradeUI::Reset(this)` unconditionally
|
||||
(both the found- and not-found-`0x1000008a` paths converge there).
|
||||
|
||||
Two more ids are referenced by id only (not bound to named fields in
|
||||
PostInit):
|
||||
|
||||
| id | Where used | Meaning |
|
||||
|---|---|---|
|
||||
| `0x1000008b` | `ListenToElementMessage` | Click → `this->vtable->SetVisible(0)`. No server notify — this is the panel's own close/"X" button; it just hides the window. |
|
||||
|
||||
### `RegisterNoticeHandler` calls in `PostInit`
|
||||
|
||||
`PostInit` also does 13 `GlobalEventHandler::GetGlobalEventHandler()->
|
||||
RegisterNoticeHandler(<id>, &this->vtable)` calls (lines ~`0x004ca17a`–
|
||||
`0x004ca2e5`). **The printed `<id>` values are decompiler noise** — most
|
||||
show as raw hex (`0x4dd231`…`0x4dd23a`, `0x186a8`, `0x186a9`, `0x186ab`)
|
||||
and one shows as a symbol (`gmKeyboardUI::ListenToElementMessage`) despite
|
||||
being an unrelated class's method — confirmed by checking `0x4dd230`
|
||||
directly: it *is* the start of `gmKeyboardUI::ListenToElementMessage`
|
||||
(`0x004dd230`), i.e. Binary Ninja is printing "nearest known symbol" for a
|
||||
raw 32-bit notice-type constant that happens to numerically fall inside
|
||||
`.text`. **Do not port these literal values** — they are opaque
|
||||
compile-time notice-type tags, not meaningful addresses. What's reliable
|
||||
is the *count* (13, matching the 13 `RecvNotice_*`/`Recv...` handlers
|
||||
below) and each handler's own address/behavior.
|
||||
|
||||
## 2. `RecvNotice_*` / notice handlers on `gmSecureTradeUI`
|
||||
|
||||
| Handler | Address | Behavior |
|
||||
|---|---|---|
|
||||
| `RecvNotice_RegisterTrade(iidInitiator?, iidPartner, arg4)` | `0x004ca5c0` | Calls `SetTradePartner(this, <partner id via a vtable call on `this-0x5f8`>)`, then `CPlayerSystem::RegisterObjectRangeHandler(playerSystem, &this->m_hashElementsRegisteredWith, arg3/*partner id*/, 0.0, 1, 0, 0.0, 0.0)` — **registers an object-range handler on the trade partner**, so leaving visual range auto-closes the trade (see `OnObjectRangeExit` below). This is the "trade window opened" handler. |
|
||||
| `RecvNotice_AddItemToTrade(itemId, side, slot)` | `0x004ca500` | `side==2` → `AddPartnerItem(itemId, slot)`; `side==1` → `AddMyItem(itemId, slot)`. |
|
||||
| `RecvNotice_RemoveItemFromTrade(itemId, side)` | `0x004ca630` | `side==2` → `RemovePartnerItem`; `side==1` → `RemoveAddedItem`. |
|
||||
| `RecvNotice_AcceptTrade(playerGuid)` | `0x004c9ce0` | `playerGuid==0`: reset-ish path — touches hash-registration bucket `[0]` (self status) then `UpdateTradeButtonState`. `playerGuid==local player`: bucket `[0]` (self) gets state `6`, then `UpdateTradeButtonState`. Otherwise (partner accepted): bucket `[4]` (== `m_pOtherTradeStatusIndicator`'s registration slot) gets state `6`. *(The "`ecx->m_hashKey->m_alphaImage(N)`" call syntax is another BN vtable-slot-mislabel — functionally this is "set the accept-status indicator element's state to N" via the notice-registration hash, most likely a plain `SetState` dispatch, not a literal alpha-image setter; treat the numeric state, not the printed method name, as ground truth.)* |
|
||||
| `RecvNotice_DeclineTrade(playerGuid)` | `0x004c9d70` | Mirror of Accept: `playerGuid==local player` → bucket `[0]` state `1` + `UpdateTradeButtonState`; otherwise (partner declined) → bucket `[4]` (partner indicator) state `0xd`. |
|
||||
| `RecvNotice_ClearTradeAcceptance()` | `0x004ca540` | Tailcalls `Reset()`. |
|
||||
| `RecvNotice_CloseTrade(arg2)` | `0x004ca550` | `Reset()`, then if hash bucket `[5]` is bound, sets that element's text to the empty string (`PStringBase::s_NullBuffer`) — likely clears a status/announcement text field. |
|
||||
| `RecvNotice_ResetTrade(arg2)` | `0x004ca670` | Tailcalls `Reset()`. |
|
||||
| `RecvNotice_TradeFailure(itemId, arg3)` | `0x004ca680` | `RemoveAddedItem(itemId)` — rolls back an optimistically-added self item that the server rejected. |
|
||||
| `RecvNotice_TradeAnItemForDummies(itemId)` | `0x004caf30` | Tailcalls `TradeAnItemForDummies(itemId)` (see below — this is the "double-click to add" convenience path, driven by a server/engine notice, not just UI). |
|
||||
| `RecvNotice_ServerSaysAttemptFailed(arg2)` | `0x004c98c0` | `this->m_hashElementsRegisteredWith...m_aInplaceBuckets[9] = nullptr` — clears a registration slot directly (no method call); likely cancels an in-flight optimistic-add tracking entry. Low confidence on exact semantics — flagged, not guessed. |
|
||||
| `RecvNotice_ServerSaysMoveItem(...)` → `ServerSaysMoveItem` | `0x004cad30` → `0x004cac20` | Full signature `(itemId, arg3, arg4, arg5, destContainerOrPlayerId, arg7, arg8, arg9)`. If `destContainerOrPlayerId == m_iidTradePartner` and `ClientTradeSystem::IsPartnerTradingItem(itemId)` and the item isn't already in `m_pOtherItemsList`: pulls the item off any pending destruction queue and calls `AddPartnerItem(itemId, ClientTradeSystem::GetItemLocationInPartnerTradeList(itemId))`. This is the "partner's item physically arrived via a container-move notice" path (items placed into the trade appear to move into a hidden container owned by the partner; this handler is what makes that show up in the partner grid). |
|
||||
| `RecvNotice_ItemAttributesChanged(itemId, arg3)` → `ItemAttributesChanged` | `0x004cad40` → `0x004ca9d0` | Only acts if `this->splitItemID != 0` (i.e. mid-split-for-trade) and `arg3 & 1`. If the changed item matches the pending split's class id and its new stack size equals the expected split remainder size, calls `AddItem(itemId, 0, 0, 0, 1)` and clears `splitItemID`. This is the completion of the "split stack before trading" flow started in `AcceptDragObject`. |
|
||||
| `OnObjectRangeExit(arg2)` | `0x004ca4c0` | If `arg2 == ClientTradeSystem::GetTradeSystem()->m_iidTradePartner`: `ClientTradeSystem::CloseTradeNegotiations()` + `Reset()`. This is the range-based auto-close wired up by `RecvNotice_RegisterTrade`'s `RegisterObjectRangeHandler` call. |
|
||||
| `OnVisibilityChanged(arg2)` | `0x004ca470` | On becoming hidden (need to confirm polarity from `arg2`, not fully traced) calls `Reset()`. |
|
||||
|
||||
`Reset()` itself (`0x004ca100`): guarded by an internal bit-flag check
|
||||
(`(this->__inner23 >> 0x11) & 1`, likely "is this element actually
|
||||
constructed/active" — early-outs otherwise). Then: `SetTradePartner(0)`
|
||||
(clears partner name), `m_pOtherTradeStatusIndicator->SetState(0xd)`
|
||||
(neutral), `FlushTradeLists()`, `SetMyItemNumber()`,
|
||||
`SetOtherItemNumber()`, `UpdateTradeButtonState()`.
|
||||
|
||||
`FlushTradeLists()` (`0x004c9ac0`): for every item currently in
|
||||
`m_pSelfItemsList`, calls `ACCWeenieObject::SetTradeState(item, 0)`
|
||||
(un-flags it as "in a trade") then `ItemList_Flush`. For every item in
|
||||
`m_pOtherItemsList`, if it's not player-owned and not already flagged
|
||||
container-location `0x3f00000`, queues it for destruction
|
||||
(`AddContentsToDestructionQueue`) — the client-side proxy objects
|
||||
representing the partner's offered items are throwaway and get destroyed
|
||||
when the trade lists are cleared.
|
||||
|
||||
## 3. Opening the trade panel
|
||||
|
||||
### Use-on-player path (confirmed)
|
||||
|
||||
`CPlayerSystem::UsingItem(itemId, arg3, arg4)` at `0x00562f70` calls
|
||||
`ItemHolder::DetermineUseResult(item)` (`0x00588460`) and switches on
|
||||
`(result - 2)`. **`case 3` → result `5` → `ClientTradeSystem::
|
||||
AttemptToOpenTradeNegotiations(GetTradeSystem(), itemId)`** at
|
||||
`0x00563022`.
|
||||
|
||||
`ItemHolder::DetermineUseResult` returns `5` specifically at
|
||||
`0x005885f7` when: the target is *not* player-owned, has no capacity/
|
||||
component-pack shortcut, isn't a "negative InqType" special object,
|
||||
isn't directly `ItemUses::IsUseable`, **and `esi->vtable->IsPlayer()`
|
||||
is true and `esi->id != <local player id>`** — i.e., **"use" on any
|
||||
other player, with no other higher-priority use-result, resolves to
|
||||
"open trade."** This is the canonical "use on player → trade" trigger.
|
||||
`ItemHolder::DetermineUseResult` — `0x00588460`.
|
||||
|
||||
`ClientTradeSystem::AttemptToOpenTradeNegotiations` — `0x0056dee0`:
|
||||
refuses (shows "You need to be in peace mode to …") if
|
||||
`ClientCombatSystem::GetCombatSystem()->combatMode != NONCOMBAT_COMBAT_MODE`.
|
||||
Otherwise sends `CM_Trade::Event_OpenTradeNegotiations(targetPlayerId)`
|
||||
(`0x0056df6a`) — the outbound wire request.
|
||||
|
||||
### Drag-item-on-player path (confirmed)
|
||||
|
||||
`ItemHolder::AttemptPlaceIn3D(arg1, arg2, arg3)` at `0x00588600` is the
|
||||
generic "item dropped onto object X in the 3D view" dispatcher. At
|
||||
`0x005887d0`:
|
||||
|
||||
```
|
||||
if (PlayerModule::DragItemOnPlayerOpensSecureTrade(&playerModule) != 0
|
||||
&& droppedOnObject->vtable->IsPlayer() != 0)
|
||||
{
|
||||
ClientTradeSystem::AttemptToTradeItem(GetTradeSystem(), targetPlayerId, droppedItemId);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
So the player-option gate is real and it's checked **before** the
|
||||
`InqType()==0x10` (give-directly) and container/lock checks that follow
|
||||
in the same function — dragging onto a player short-circuits straight to
|
||||
trade-attempt when the option is on, before any "give" logic runs.
|
||||
|
||||
- Option getter/setter: `PlayerModule::DragItemOnPlayerOpensSecureTrade`
|
||||
(`0x005d31b0`) / `PlayerModule::SetDragItemOnPlayerOpensSecureTrade`
|
||||
(`0x005d31c0`). Player-option enum id: `DragItemOnPlayerOpensSecureTrade_PlayerOption`.
|
||||
StringTable label/help keys: `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade`
|
||||
/ `..._Help`, registered via `compute_str_hash` at `0x004a0f5f` /
|
||||
`0x004a0f85`, added to the options page via
|
||||
`PlayerOptionPage::AddToggleOption` at `0x004a0fa4`.
|
||||
|
||||
`ClientTradeSystem::AttemptToTradeItem(targetPlayerId, itemId)` —
|
||||
`0x0056df80`: requires the item be player-owned. If already trading with
|
||||
someone:
|
||||
- same partner → `CM_Trade::SendNotice_TradeAnItemForDummies(itemId)`
|
||||
(adds the item directly to the already-open trade — the "convenience"
|
||||
add-while-already-negotiating path).
|
||||
- different partner → refuses with "You are already trading with
|
||||
som…".
|
||||
If not yet trading: calls `ItemHolder::UseObject(targetPlayerId, 0, 0)`
|
||||
(i.e. re-enters the same **use** path as above, effectively "use the
|
||||
target player") and stashes `attemptTradeToPlayerID` /
|
||||
`attemptTradeObjectID` for later. Once
|
||||
`ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` (`0x0056e050`, the
|
||||
inbound "trade window opened" handler that also fires
|
||||
`CM_Trade::SendNotice_RegisterTrade` → `gmSecureTradeUI::RecvNotice_RegisterTrade`
|
||||
above) sees `m_iidTradePartner == attemptTradeToPlayerID`, it calls
|
||||
`AttemptToTradeItem` again to actually queue the drag-dropped item into
|
||||
the now-open trade.
|
||||
|
||||
### Internal UI-queue notice ids (bonus — confirms §1's `PostInit` id noise is noise)
|
||||
|
||||
`CM_Trade::DispatchUI_Recv_*` (the layer between the network/engine queue
|
||||
and the `RecvNotice_*` UI calls) gate on small, contiguous internal
|
||||
tag values — these are **not** the raw wire opcodes, but they cross-check
|
||||
cleanly against each other and confirm the `PostInit` `RegisterNoticeHandler`
|
||||
hex noise (§1) is unrelated decompiler artifact, not meaningful data:
|
||||
|
||||
| Notice | Internal tag | Dispatch fn addr |
|
||||
|---|---|---|
|
||||
| RegisterTrade | `0x1fd` | `0x006acf20` |
|
||||
| OpenTrade | `0x1fe` | `0x006acef0` |
|
||||
| CloseTrade | `0x1ff` | `0x006ace90` |
|
||||
| AddToTrade | `0x200` | `0x006ace20` |
|
||||
| RemoveFromTrade | `0x201` | `0x006acf80` |
|
||||
| AcceptTrade | `0x202` | `0x006ace09`/`0x006acdf0` |
|
||||
| DeclineTrade | `0x203` | `0x006ace60`(dispatch fn header `0x006acec0`) |
|
||||
| ResetTrade | `0x205` | `0x006acfb0` |
|
||||
| TradeFailure | `0x207` | `0x006acfe0` |
|
||||
| ClearTradeAcceptance | `0x208` | `0x006ace60` |
|
||||
|
||||
## 4. Item grids
|
||||
|
||||
- **Your grid**: element id `0x10000088`, bound to `m_pSelfItemsList`,
|
||||
type `UIElement_ItemList` (element-type-id `0x10000031`). This list
|
||||
registers itself as an `ItemListDragHandler` target with `this`
|
||||
(`gmSecureTradeUI`) as handler — items are dragged **in** here by the
|
||||
player. Individual entries, when read back with
|
||||
`UIElement_ListBox::GetItem`, are `DynamicCast(0x10000032)` — a
|
||||
`UIElement_UIItem` icon wrapping an `ACCWeenieObject` (`weenObj` field).
|
||||
- **Partner's grid**: element id `0x10000081`, bound to
|
||||
`m_pOtherItemsList`, same `UIElement_ItemList` type, **no drag handler
|
||||
registered** — populated only by network notices
|
||||
(`AddPartnerItem`/`RemovePartnerItem`/`ServerSaysMoveItem`), not by
|
||||
local drag-drop.
|
||||
- **Counts**: `SetMyItemNumber()` (`0x004c98d0`) / `SetOtherItemNumber()`
|
||||
(`0x004c9970`) build a `StringInfo` with
|
||||
`SetStringIDandTableEnum(&info, <ID>, 0x10000001)` +
|
||||
`AddVariable_Int(count)` and write the resolved string into
|
||||
`m_pSelfTotalItemsLabel` / `m_pOtherTotalItemsLabel`. The printed `<ID>`
|
||||
literal is `0`, which is almost certainly the decompiler showing the
|
||||
static pre-initializer value of the global `ID_SecureTrade_TotalItemsLabel`
|
||||
(see §5) rather than the true runtime-computed hash — same class of
|
||||
artifact as the `PostInit` notice ids. Table-enum `0x10000001` is a
|
||||
StringTable category constant, not further resolved here.
|
||||
- **Accept/decline is presented via**:
|
||||
1. `m_pTradeButton` (id `0x10000086`) — one physical button whose
|
||||
current `m_state` decides what a click does (see §1 click table):
|
||||
`m_state==6` → click triggers `AcceptTheTrade`; `m_state==1` →
|
||||
click triggers `DeclineTheTrade`. `UpdateTradeButtonState()`
|
||||
(`0x004c9700`) disables the button (state `0xd`) whenever total
|
||||
items across both sides is `0`, and re-enables (state `1`) once
|
||||
items exist. **Caution**: `UIElement_Button::SetState` (`0x00471da0`)
|
||||
shows this button uses a "latch" attribute pair (attrs `0xb`/`0xe`)
|
||||
where `SetState(6)`/`SetState(1)` toggle a latch flag and
|
||||
early-return *without* necessarily rewriting `m_state` to that
|
||||
literal value unless the latch was already in the requested
|
||||
position, in which case it falls through to the generic
|
||||
disabled-state path which does write `m_state`. The exact
|
||||
accept/decline toggle semantics are therefore genuinely convoluted
|
||||
in the retail binary; recommend the port drive the button's visual
|
||||
state from `ClientTradeSystem`'s own accepted/declined booleans
|
||||
rather than replicating this latch dance literally, and validate
|
||||
against a live retail trade if uncertain (this is exactly the kind
|
||||
of "state interacts with prior state in ways not obvious from
|
||||
reading" case the CLAUDE.md's cdb workflow exists for).
|
||||
2. `m_pOtherTradeStatusIndicator` (id `0x1000007f`) — the partner-side
|
||||
accept/decline indicator icon, driven directly by
|
||||
`RecvNotice_AcceptTrade`/`RecvNotice_DeclineTrade` (state `6` =
|
||||
accepted, `0xd` = neutral/declined) and reset to `0xd` on every
|
||||
`AddMyItem`/`AddPartnerItem`/`RemoveAddedItem`/`RemovePartnerItem`
|
||||
(any list change silently un-accepts the visual, matching retail's
|
||||
"adding an item clears both sides' acceptance" rule).
|
||||
3. Drag-affordance color feedback during hover uses the same generic
|
||||
accept/reject drag-state pair used elsewhere in the UI:
|
||||
`UIElement_UIItem::SetDragAcceptState(item, 0x10000040)` (acceptable
|
||||
— green) / `0x10000041` (rejected — red), set from
|
||||
`OnItemListDragOver` (`0x004ca980`) via `DragItemAcceptable`.
|
||||
|
||||
`DragItemAcceptable(itemId, quiet)` (`0x004ca6a0`): if the item is
|
||||
player-owned and not already in the self list → acceptable. If not
|
||||
player-owned: acceptable only path is absent (falls to `return 0`); if
|
||||
`quiet==0` it also posts a rejection `StringInfo` via
|
||||
`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` (the literal string text
|
||||
itself is unresolved — printed as a mislabeled vtable-slot symbol,
|
||||
another string-adjacent-to-vtable BN artifact, not a real symbol
|
||||
reference).
|
||||
|
||||
`AddItem(itemId, slot, quietFlag, splitAllowedFlag, fromRecursion)`
|
||||
(`0x004ca780`): if the item has no contained items/containers (i.e. not
|
||||
itself a container), inserts it directly into `m_pSelfItemsList` and
|
||||
calls `ClientTradeSystem::AddItemToSelfTradeList` (the outbound wire
|
||||
call). If it *is* a container (has contained items) and `splitAllowedFlag`
|
||||
is set, it instead announces "Trading contents of %s" and recursively
|
||||
calls `AddItem` for every contained item — **dragging a container into
|
||||
the trade grid trades its contents individually, not the container
|
||||
itself.**
|
||||
|
||||
`AcceptDragObject(itemId)` (`0x004caa40`): if `DragItemAcceptable`
|
||||
passes and the dropped item's current stack size already equals the max
|
||||
split size, adds it directly. Otherwise attempts
|
||||
`ItemHolder::AttemptToPlaceInContainer` to split off the correct amount
|
||||
first (stashing `splitItemID`/`splitItemClassID`/`splitItemStackSize`,
|
||||
announcing "Splitting the %s before trading …"); the actual add happens
|
||||
later when `RecvNotice_ItemAttributesChanged`/`ItemAttributesChanged`
|
||||
sees the split completion (§2). If the split attempt itself fails,
|
||||
announces "Cannot split the stack to trade …".
|
||||
|
||||
`TradeAnItemForDummies(itemId)` (`0x004cad50`): the convenience
|
||||
"just trade this stack, splitting-and-all" entry point invoked from the
|
||||
`RecvNotice_TradeAnItemForDummies` notice (fired e.g. from
|
||||
`AttemptToTradeItem`'s same-partner re-add case). Refuses with "You must
|
||||
split the stack before …" if the item is the globally
|
||||
`ACCWeenieObject::selectedID` and a split is already in flight
|
||||
(`GenItemHolder::splitSize == maxSplitSize`); otherwise calls `AddItem`
|
||||
directly.
|
||||
|
||||
`HandleDropRelease` (`0x004cae10`) / message id `0x15` in
|
||||
`ListenToElementMessage`: only processes a drop if the drop's ancestor
|
||||
chain lands inside `m_pSelfItemsList` (`UIElement::IsAncestorOfMe`) —
|
||||
confirms drops are only ever accepted onto your own grid, never the
|
||||
partner's.
|
||||
|
||||
## `ListenToElementMessage` click table (`0x004cae80`)
|
||||
|
||||
| `idMessage` | `idElement` | Action |
|
||||
|---|---|---|
|
||||
| `1` (click) | `0x10000086` (trade button) | `m_state==6` → `AcceptTheTrade()`; `m_state==1` → `DeclineTheTrade()`. |
|
||||
| `1` (click) | `0x1000008a` (clear-all) | `ClientTradeSystem::ResetTrade(GetTradeSystem())` — outbound reset. |
|
||||
| `1` (click) | `0x1000008b` (close/X) | `this->vtable->SetVisible(0)` — local-only hide, no wire traffic. |
|
||||
| `0x15` (drop) | — | `HandleDropRelease` (self-grid-only, see §4). |
|
||||
|
||||
`AcceptTheTrade()` (`0x004c9a10`): before sending accept, verifies the
|
||||
locally-displayed item counts (`GetNumUIItems` on both lists) match the
|
||||
server-known counts (`ClientTradeSystem::GetNumSelfObjectsInTrade`/
|
||||
`GetNumPartnerObjectsInTrade`); mismatch →
|
||||
`ClientTradeSystem::NotifyServerThatTradeIsOutOfSync()` instead of
|
||||
accepting — an explicit desync guard the port should replicate.
|
||||
`DeclineTheTrade()` (`0x004c9a90`) is unconditional:
|
||||
`ClientTradeSystem::DeclineTrade()`.
|
||||
|
||||
## 5. StringTable keys
|
||||
|
||||
Only one trade-panel-specific key was found via `compute_str_hash("ID_...")`
|
||||
scan of the whole file:
|
||||
|
||||
| Key | Hash-init address |
|
||||
|---|---|
|
||||
| `ID_SecureTrade_TotalItemsLabel` | `0x006f2d8d` |
|
||||
|
||||
Related but not-panel-body keys (player options / chat, not the panel
|
||||
itself): `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade` (+`_Help`) at
|
||||
`0x004a0f5f`/`0x004a0f85`; `ID_PlayerOption_IgnoreTradeRequests` (+`_Help`)
|
||||
at `0x004a0eee`/`0x004a0f14`; `ID_ChatOption_TextFilter_Trade` (+`_Desc`)
|
||||
at `0x006f06cd`/`0x006f06ed`; `ID_Chat_ChatTargetMenuTrade` /
|
||||
`ID_Chat_TellToTrade` at `0x006f39ad`/`0x006f3a6d`.
|
||||
|
||||
**No other `ID_SecureTrade*` or `ID_Trade*` keys exist in the pseudo-C.**
|
||||
Player-name and other-side labels are populated directly from
|
||||
`ACCWeenieObject::GetObjectNameWide` (not StringTable), and the button's
|
||||
own caption/tooltip text is presumably baked into the LayoutDesc/DAT
|
||||
authoring for element `0x10000086` rather than resolved at runtime here —
|
||||
the porting engineer should pull the actual authored panel (element class
|
||||
`0x10000012`, its children `0x1000007e`–`0x1000008b`) from the game's
|
||||
LayoutDesc DAT resource via the existing `LayoutImporter`/UI-Studio
|
||||
tooling to get real control names/captions/positions; this decomp pass
|
||||
only recovers behavior, not layout.
|
||||
|
||||
## NOT FOUND
|
||||
|
||||
- The literal wire/network opcode for the trade `GameAction`/`GameEvent`
|
||||
family (as opposed to the internal `0x1fd`–`0x208` UI-queue tags in
|
||||
§3) was not located in `acclient.h` or the pseudo-C by direct grep;
|
||||
cross-check `references/ACE/` server-side trade handlers if the exact
|
||||
byte-level wire opcode is needed for a lane-B/network task.
|
||||
- The exact text of the two `StringInfo`/`ECM_UI::SendNotice_DisplayStringInfo`
|
||||
messages in `DragItemAcceptable` and one in `AcceptDragObject` that show
|
||||
as mislabeled vtable-slot symbols instead of string literals (BN
|
||||
string-adjacent-to-vtable artifact) — content unrecoverable from this
|
||||
file alone.
|
||||
- Full struct layout / field offsets for `gmSecureTradeUI` and
|
||||
`ClientTradeSystem` are not present in `acclient.h` (not reconstructed
|
||||
in this PDB pass); field names above are taken from the pseudo-C's own
|
||||
`this->fieldName` labels, which the decompiler DID resolve correctly
|
||||
(these are real PDB member names, unlike the notice-id/string-literal
|
||||
artifacts flagged above).
|
||||
316
docs/research/2026-08-14-trade-laneB-wire.md
Normal file
316
docs/research/2026-08-14-trade-laneB-wire.md
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# 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.
|
||||
440
docs/research/2026-08-14-trade-laneC-seams.md
Normal file
440
docs/research/2026-08-14-trade-laneC-seams.md
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
# Secure-trade seam map (Lane C research)
|
||||
|
||||
Read-only audit. Every claim below is anchored file:line. "NOT FOUND"
|
||||
means the search came up empty, not that the answer is assumed absent.
|
||||
|
||||
## 1. The existing option and its two current dispatch sites
|
||||
|
||||
Enum member: `CharacterOptionId.DragItemOnPlayerOpensSecureTrade`
|
||||
(`src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs:134`, registered
|
||||
`0x04000000u`). Mirrored bit constant at
|
||||
`src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:216` and
|
||||
`src/AcDream.Core.Net/Messages/SocialActions.cs:450` (`= 0x17`, a
|
||||
different unrelated numbering — that second one is a
|
||||
`CharacterOptions1Bits`/switch-ordinal, not the wire bit; don't conflate
|
||||
them). Read today via `RuntimeCharacterState.DragItemOnPlayerOpensSecureTrade`
|
||||
(`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:629-632,721-722`),
|
||||
which App threads through as a delegate in
|
||||
`InteractionRetainedUiComposition.cs:325-326`:
|
||||
```
|
||||
dragOnPlayerOpensSecureTrade: () =>
|
||||
d.Character.Options.DragItemOnPlayerOpensSecureTrade,
|
||||
```
|
||||
into `ItemInteractionController`'s ctor field `_dragOnPlayerOpensSecureTrade`
|
||||
(`src/AcDream.App/UI/ItemInteractionController.cs:61,111,148`).
|
||||
|
||||
**Drag-onto-player detection path.** `ItemInteractionController.PlaceIn3D`
|
||||
(`ItemInteractionController.cs:1034-1063`) is the drop handler for a
|
||||
retail inventory drag released over a world/UI target
|
||||
(`ItemHolder::AttemptPlaceIn3D @ 0x00588600`, per its doc comment). It
|
||||
builds `ItemPlacementPolicyInput` with
|
||||
`DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade()`
|
||||
(line 1058) and calls `ItemInteractionPolicy.DecidePlacement` (line 1049).
|
||||
|
||||
The actual branch (`src/AcDream.Core/Items/ItemInteractionPolicy.cs:372-374`):
|
||||
```csharp
|
||||
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
|
||||
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.StartSecureTrade,
|
||||
input.Item.Id, target.Id, input.SplitSize));
|
||||
|
||||
if (target.Type == ItemType.Creature)
|
||||
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.GiveToTarget,
|
||||
input.Item.Id, target.Id, input.SplitSize));
|
||||
```
|
||||
So the option is a straight `if`: option **true** + target is a player →
|
||||
`StartSecureTrade` action; option **false** (or target not a player) +
|
||||
target is any `ItemType.Creature` (players are `ItemType.Creature` too)
|
||||
→ `GiveToTarget` action. The vanilla give path
|
||||
(`GiveToTarget`) is fully wired: `ExecutePlacementActions` dispatches it
|
||||
through `_sendGive` → `WorldSession.SendGiveObject`
|
||||
(`ItemInteractionController.cs:1204-1221`, wired at
|
||||
`InteractionRetainedUiComposition.cs:323-324`).
|
||||
|
||||
**`StartSecureTrade` today is a stub.** In `ExecutePlacementActions`'
|
||||
switch, `StartSecureTrade` has no `case` — it falls to `default:`
|
||||
(`ItemInteractionController.cs:1263-1268`), which invokes
|
||||
`_auxiliaryAction`/`PolicyActionRequested` (both effectively unhandled
|
||||
for this action kind — see §2) and otherwise shows the toast built by
|
||||
`PolicyActionMessage`: `"Secure trade is not open."`
|
||||
(`ItemInteractionController.cs:1296-1297`).
|
||||
|
||||
## 2. Use-on-selected-player today
|
||||
|
||||
Keybind: `InputAction.UseSelected` →
|
||||
`SelectionInteractionController.UseCurrentSelection()`
|
||||
(`src/AcDream.App/Interaction/SelectionInteractionController.cs:71-73,217-233`).
|
||||
It enqueues `RuntimeQueuedInteractionKind.Use` via `EnqueueIdentityBound`
|
||||
(no target-type special-case at this layer). The queue drains through
|
||||
`DispatchQueuedInteraction`
|
||||
(`SelectionInteractionController.cs:842-865`):
|
||||
```csharp
|
||||
case RuntimeQueuedInteractionKind.Use:
|
||||
_items.UseSelectedOrEnterMode(identity.ServerGuid);
|
||||
break;
|
||||
```
|
||||
`ItemInteractionController.UseSelectedOrEnterMode`
|
||||
(`ItemInteractionController.cs:553-563`) calls `ActivateItem(selectedObjectId)`
|
||||
when a selection exists. `ActivateItem`
|
||||
(`ItemInteractionController.cs:657-690`) builds `ItemUsePolicyInput` with
|
||||
`Source: Snapshot(item)` = the SELECTED object (the target player, in
|
||||
this scenario) and calls `ItemInteractionPolicy.DecideUse`.
|
||||
|
||||
**The retail-cited dispatch site already classifies a selected player as
|
||||
OpenSecureTrade.** `ItemInteractionPolicy.DetermineUseResult`
|
||||
(`src/AcDream.Core/Items/ItemInteractionPolicy.cs:181-227`, cited as
|
||||
`ItemHolder::DetermineUseResult @ 0x00588460`):
|
||||
```csharp
|
||||
if (ItemUseability.IsUseable(item.Useability))
|
||||
return ItemPrimaryUseResult.ItemUse;
|
||||
|
||||
if (item.IsPlayer && item.Id != playerId)
|
||||
return ItemPrimaryUseResult.OpenSecureTrade;
|
||||
```
|
||||
(lines 220-224). `DecideUse` (lines 229-312) calls this at line 239 and,
|
||||
because `OpenSecureTrade` (5) falls in the classified range
|
||||
`[PlaceInBackpack(2)..BeginGame(7)]` (line 241-242 comment: "Exact
|
||||
retail bound: UseObject classifies 2..7, deliberately excluding 8"),
|
||||
routes to `BuildUsingItemActions`, which maps
|
||||
`ItemPrimaryUseResult.OpenSecureTrade => ItemPolicyActionKind.OpenSecureTrade`
|
||||
(confirmed mapping near line 407 of the same file).
|
||||
|
||||
So: **pressing Use with another player selected already produces an
|
||||
`OpenSecureTrade` policy action end-to-end through the ported retail
|
||||
classifier** — no new classification logic is needed. The gap is purely
|
||||
on the execution side: in `ExecuteUseActions`'
|
||||
switch, `OpenSecureTrade` has no `case` and falls to the same `default:`
|
||||
stub as `StartSecureTrade` (`ItemInteractionController.cs:1142-1149`),
|
||||
producing the identical "Secure trade is not open." toast
|
||||
(`PolicyActionMessage`, line 1296-1297).
|
||||
|
||||
**Where a trade-open branch goes:** add
|
||||
`case ItemPolicyActionKind.OpenSecureTrade:` /
|
||||
`case ItemPolicyActionKind.StartSecureTrade:` to both
|
||||
`ExecuteUseActions` (`ItemInteractionController.cs:1072-1150`) and
|
||||
`ExecutePlacementActions` (`ItemInteractionController.cs:1160-1271`),
|
||||
each calling a new delegate (mirroring `_sendGive`) that opens the trade
|
||||
window / sends the wire open request with `action.TargetId`.
|
||||
|
||||
`RetailItemConfirmationController`
|
||||
(`src/AcDream.App/UI/RetailItemConfirmationController.cs:41-56`) is the
|
||||
only current subscriber of `PolicyActionRequested`, and it only handles
|
||||
`ConfirmPlayerKillerSwitch`/`ConfirmNonPlayerKillerSwitch`/
|
||||
`ConfirmVolatileRare` — it silently ignores `OpenSecureTrade`/
|
||||
`StartSecureTrade` (`message is null` → early return, line 50-51). A new
|
||||
trade controller subscribing to the same event is a viable second wiring
|
||||
point if a dedicated ItemInteractionController delegate isn't preferred,
|
||||
but the delegate approach matches how `GiveToTarget` is wired (a named
|
||||
`_sendGive` ctor param, not the generic auxiliary-action escape hatch).
|
||||
|
||||
## 3. Vendor panel as the mount template
|
||||
|
||||
`VendorUiController` (`src/AcDream.App/UI/Layout/VendorUiController.cs`)
|
||||
+ its mount method `RetailUiRuntime.MountVendor`
|
||||
(`src/AcDream.App/UI/RetailUiRuntime.cs:3368-3471`). Recipe:
|
||||
|
||||
1. Under `_bindings.Assets.DatLock`, import the LayoutDesc via
|
||||
`LayoutImporter.Import(dats, VendorUiController.LayoutId, VendorUiController.RootId, ...)`
|
||||
(lines 3374-3382) and resolve any empty-slot sprites needed for item
|
||||
strips via `ItemListCellTemplate.ResolveEmptySprite` (lines 3386-3401).
|
||||
2. `RetailWindowFrame.Mount(Host.Root, root, _bindings.Assets.ResolveSprite, new RetailWindowFrame.Options { WindowName = WindowNames.Vendor, Chrome = ..., Left/Top/ContentWidth/ContentHeight from root, Visible = false, Resize flags, ConstrainDragToParent/ConstrainResizeToParent, DrawChromeCenter })`
|
||||
(lines 3410-3434) — returns a `RetailWindowHandle`.
|
||||
3. `VendorController = VendorUiController.Bind(layout, b.State, handle,
|
||||
b.ResolveIcon, _bindings.Inventory.Objects, _bindings.Inventory.PlayerGuid,
|
||||
b.ItemInteraction, b.Selection, StackSplitQuantity,
|
||||
_bindings.Assets.DefaultFont, _bindings.Assets.DebugFont,
|
||||
_bindings.Assets.ResolveSprite, emptySlotSprite, buyingEmptySlotSprite,
|
||||
sellingEmptySlotSprite, DialogFactory, b.DisplaySystemMessage)`
|
||||
(lines 3443-3462) where `b = _bindings.Vendor` (a `VendorRuntimeBindings`,
|
||||
`RetailUiRuntime.cs:340-354`).
|
||||
4. `Host.WindowManager.AttachController(WindowNames.Vendor, VendorController)`
|
||||
(line 3469).
|
||||
|
||||
Same shape used by the social panel's `MountSocialPanel`
|
||||
(`RetailUiRuntime.cs:2741-2965`), which additionally shows the
|
||||
`ActivateTabs()` call for tabbed panels (line 2929) and
|
||||
`_panelUi.RegisterMainPanel(...)` for panel-catalog/toolbar-button
|
||||
registration (lines 2956-2963) — a secure-trade window is a two-sided
|
||||
non-tabbed floating window like Vendor, so `MountVendor` is the closer
|
||||
template.
|
||||
|
||||
**Where mount methods get called from:** `RetailUiRuntime.Initialize()`
|
||||
(`RetailUiRuntime.cs:455-484`) calls every `MountXxx()` in a fixed
|
||||
sequence, e.g. `MountSocialPanel(); MountCharacter(); MountPlugins();
|
||||
MountInventory(); MountExternalContainer(); MountVendor();
|
||||
MountItemCooldowns();` (lines 475-481). A `MountSecureTrade()` call
|
||||
would join this list, most naturally right after `MountVendor()` since
|
||||
both are two-participant item-exchange windows sharing icon/drag
|
||||
machinery.
|
||||
|
||||
**Runtime state callback shape:** `VendorRuntimeBindings`
|
||||
(`RetailUiRuntime.cs:340-354`) is built in
|
||||
`InteractionRetainedUiComposition.cs:804-809`:
|
||||
```csharp
|
||||
Vendor: new VendorRuntimeBindings(
|
||||
d.Inventory.Vendor,
|
||||
iconComposer.GetIcon,
|
||||
itemInteraction,
|
||||
d.Actions.Selection,
|
||||
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
||||
```
|
||||
i.e. it hands the controller the live `VendorState` object directly
|
||||
(not a snapshot func) plus the icon resolver, item-interaction
|
||||
controller, selection state, and a system-message sink. A
|
||||
`TradeRuntimeBindings` record would follow the identical shape: a live
|
||||
`RuntimeTradeState` (or its view), icon resolver, item interaction,
|
||||
selection, message sink.
|
||||
|
||||
## 4. Runtime owner shape
|
||||
|
||||
`RuntimeInventoryState` (`src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`)
|
||||
is constructed at `GameRuntime.cs:201-207`:
|
||||
```csharp
|
||||
context.Inventory = new RuntimeInventoryState(context.EntityObjects);
|
||||
```
|
||||
— it takes the shared `RuntimeEntityObjectLifetime` (constructed at
|
||||
`GameRuntime.cs:191-199`) and exposes the SAME `ClientObjectTable` via
|
||||
`Objects => _entityObjects.Objects` (`RuntimeInventoryState.cs:78`); it
|
||||
creates no second object model. Its own children
|
||||
(`ExternalContainers`, `ItemMana`, `Shortcuts`, `Transactions`,
|
||||
`Vendor`, `VendorItems`) are constructed in its ctor
|
||||
(`RuntimeInventoryState.cs:53-76`) — `Vendor = new VendorState()` at
|
||||
line 67 is the closest existing analogue to a future `Trade` child:
|
||||
**vendor state lives as a child of `RuntimeInventoryState`, not as a
|
||||
GameRuntime-level sibling**, whereas Fellowship/Allegiance are top-level
|
||||
siblings (`GameRuntime.cs:236,244`). A secure-trade owner has a
|
||||
plausible case for either shape — it manipulates inventory items (favors
|
||||
the Vendor precedent, nested under `RuntimeInventoryState`) but also has
|
||||
its own two-party negotiation lifecycle independent of container state
|
||||
(favors the Fellowship/Allegiance precedent, a GameRuntime-level
|
||||
sibling). Either is a straight port of an existing pattern; no third
|
||||
shape needs inventing.
|
||||
|
||||
**Generation reset:** `RuntimeGenerationReset`
|
||||
(`src/AcDream.Runtime/RuntimeGenerationReset.cs`) is constructed with
|
||||
every owner needing session-scoped clearing, including
|
||||
`_fellowship`/`_allegiance` (ctor params, lines 112-113,129-130) and
|
||||
drives `_inventory.ResetVendor()` at its `Vendor`-family stage (line
|
||||
288) and `_fellowship.ResetSession()` / `_allegiance.ResetSession()` at
|
||||
their own stages (lines 317-321, enum values `Fellowship = 12`,
|
||||
`Allegiance = 13` at lines 41,55). A new trade owner needs either a new
|
||||
`ResetTrade()` call folded into the existing Vendor-family reset stage
|
||||
(if nested under Inventory) or its own new
|
||||
`RuntimeGenerationResetStage` entry + ctor param (if a GameRuntime-level
|
||||
sibling) — same file, same pattern either way.
|
||||
|
||||
**`_bindings.Social`/`_bindings.Inventory` reach path (App side):**
|
||||
`d.Inventory.Vendor` in `InteractionRetainedUiComposition.cs:805` and
|
||||
`d.Runtime.Fellowship`/`d.Runtime.Allegiance` in the Social binding block
|
||||
(`InteractionRetainedUiComposition.cs:890-892`,
|
||||
`() => d.Runtime.Fellowship.Snapshot`) show the two reach patterns: a
|
||||
direct owned-state object (`d.Inventory.Vendor`, mutable, App reads it
|
||||
live) vs. a `IGameRuntimeView`-typed snapshot accessor
|
||||
(`d.Runtime.Fellowship.Snapshot`, immutable projection). `d.Inventory`
|
||||
and `d.Runtime` are both fields on `InteractionRetainedUiDependencies`
|
||||
(same file, referenced throughout — not independently re-verified here
|
||||
since both usages above are load-bearing evidence of the shape).
|
||||
|
||||
**(a) Inbound events reaching the owner** — the FellowshipUpdate/
|
||||
FriendsUpdate routing pattern, in two hops:
|
||||
|
||||
1. `GameEventWiring.RegisterAll` (or its per-domain overload) exposes
|
||||
optional `Action<T>?` delegate holes per parsed event type, e.g.
|
||||
`onFellowshipUpdateFellow` (`src/AcDream.Core.Net/GameEventWiring.cs:108`,
|
||||
registered conditionally at lines 252-258:
|
||||
`registrar.Register(GameEventType.FellowshipUpdateFellow, e => { var update = GameEvents.ParseFellowshipUpdateFellow(e.Payload.Span); if (update is not null) onFellowshipUpdateFellow(update.Value); })`).
|
||||
2. `LiveSessionEventRouter` (`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs`)
|
||||
wires those holes to the Runtime owner's `Apply*` methods,
|
||||
conditionally on the owner being supplied (lines 256-284):
|
||||
```csharp
|
||||
onFellowshipUpdateFellow: social.Fellowship is { } fellowshipUpdate
|
||||
? fellowshipUpdate.ApplyUpdateFellow
|
||||
: null,
|
||||
```
|
||||
where `social` is a `LiveSocialSessionBindings` record
|
||||
(`LiveSessionEventRouter.cs:72-88`) carrying `Fellowship`/`Allegiance`
|
||||
owner references.
|
||||
3. `LiveSessionRuntimeFactory`
|
||||
(`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:258-273`)
|
||||
constructs the router and supplies the actual owners:
|
||||
```csharp
|
||||
var route = new LiveSessionEventRouter(
|
||||
...,
|
||||
new LiveSocialSessionBindings(
|
||||
...,
|
||||
Fellowship: _domain.Runtime.FellowshipOwner,
|
||||
Allegiance: _domain.Runtime.AllegianceOwner));
|
||||
```
|
||||
`GameRuntime.FellowshipOwner`/`AllegianceOwner` are typed getters over
|
||||
the same `context.Fellowship`/`context.Allegiance` fields
|
||||
(`GameRuntime.cs:463-464`).
|
||||
|
||||
A trade owner's inbound wiring is the same three-hop shape: parse
|
||||
`GameEventType.OpenTrade`/`AddToTrade`/`AcceptTrade`/etc in
|
||||
`GameEvents.cs` (partially started — see §5), add delegate holes +
|
||||
conditional registration in `GameEventWiring.cs`, add a
|
||||
`Trade`/`RuntimeTradeState?` field to a bindings record analogous to
|
||||
`LiveSocialSessionBindings`, and supply `_domain.Runtime.TradeOwner` at
|
||||
the `LiveSessionRuntimeFactory.cs:258-273` construction site.
|
||||
|
||||
**(b) UI borrowing it:** the `_bindings.Social` record shape
|
||||
(`SocialRuntimeBindings`, `RetailUiRuntime.cs:264-286`) is a flat record
|
||||
of `Func<TSnapshot>` accessors + command delegates + shared `SelectionState`/
|
||||
`LocalPlayerGuid` accessors, built in
|
||||
`InteractionRetainedUiComposition.cs:890-...` by closing over
|
||||
`d.Runtime.Fellowship`/`d.Runtime.Allegiance` for reads and
|
||||
`late.GameRuntime.FellowshipXxx(...)` (a `DeferredGameRuntimeStateCommands`
|
||||
instance, `src/AcDream.App/Composition/InteractionUiRuntimeSources.cs:23-140`)
|
||||
for generation-gated writes. Each `DeferredGameRuntimeStateCommands`
|
||||
method (e.g. `FellowshipCreate`, lines 126-128) calls
|
||||
`Invoke((commands, generation) => commands.Fellowship.Create(generation, ...))`
|
||||
against an `IGameRuntimeCommands` interface, whose concrete
|
||||
`DirectGameRuntimeCommandAdapter` implementation
|
||||
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:804-825`
|
||||
for `Create`) validates the generation token then calls the matching
|
||||
`WorldSession.SendXxx`. A `TradeRuntimeBindings` + trade commands on
|
||||
`IGameRuntimeCommands` would follow this exact three-layer shape
|
||||
(App binding record → `DeferredGameRuntimeStateCommands` method →
|
||||
`IGameRuntimeCommands.Trade.Xxx(generation, ...)` →
|
||||
`DirectGameRuntimeCommandAdapter` → `WorldSession.SendXxx`).
|
||||
|
||||
## 5. WorldSession send pattern
|
||||
|
||||
All outbound sends in `src/AcDream.Core.Net/WorldSession.cs` follow:
|
||||
```csharp
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(SomeRequests.BuildSomething(seq, ...args));
|
||||
```
|
||||
Three examples:
|
||||
- `SendDropItem(uint itemGuid)` — `WorldSession.cs:2558-2562`.
|
||||
- `SendGiveObject(uint targetGuid, uint itemGuid, uint amount)` —
|
||||
`WorldSession.cs:2569-2574`, builder `InventoryActions.BuildGiveObjectRequest`.
|
||||
- `SendAppraise(uint targetGuid)` — `WorldSession.cs:2648-2652`, builder
|
||||
`AppraiseRequest.Build`.
|
||||
|
||||
The builder classes live in `src/AcDream.Core.Net/Messages/` (one static
|
||||
class per message family, e.g. `VendorRequests.cs` for Buy/Sell,
|
||||
`InventoryActions.cs` for drop/give/wield). `VendorRequests.BuildBuy`
|
||||
(`src/AcDream.Core.Net/Messages/VendorRequests.cs:54-70+`) is the
|
||||
richest documented example: constants for envelope/opcode
|
||||
(`GameActionEnvelope = 0xF7B1u`, `BuyOpcode = 0x005Fu`) and an
|
||||
extensive doc comment citing the retail decompiled sender + 3 other
|
||||
cross-checked references for the wire layout — the expected citation
|
||||
depth for a new `TradeRequests.BuildOpenTrade`/`BuildAddToTrade`/etc.
|
||||
|
||||
**NOT FOUND: no `TradeRequests`/`TradeActions` builder class exists yet**
|
||||
(grepped `src/` for both names — the only hits are an unrelated
|
||||
`IgnoreTradeRequests` character-option enum member,
|
||||
`src/AcDream.Core.Net/Messages/SocialActions.cs:430`). **NOT FOUND: no
|
||||
`WorldSession.SendXxx` for any trade opcode** (grepped `WorldSession.cs`
|
||||
for "Trade" — only comments about the chat "Trade" room, e.g. line 467).
|
||||
Every trade send must be built from scratch on this pattern.
|
||||
|
||||
**Partial scaffolding that DOES exist:** `GameEventType` already has the
|
||||
ten trade opcodes (`src/AcDream.Core.Net/Messages/GameEventType.cs:62-70`:
|
||||
`RegisterTrade = 0x01FD`, `OpenTrade = 0x01FE`, `CloseTrade = 0x01FF`,
|
||||
`AddToTrade = 0x0200`, `RemoveFromTrade = 0x0201`,
|
||||
`AcceptTrade = 0x0202`, `DeclineTrade = 0x0203`, `ResetTrade = 0x0205`,
|
||||
`TradeFailure = 0x0207`, `ClearTradeAcceptance = 0x0208`), and
|
||||
`GameEvents.cs` has three inbound parsers already written but
|
||||
**unregistered** anywhere: `ParseTradeFailure` (line 466),
|
||||
`ParseAddToTrade` → `record struct AddToTrade(uint ItemGuid, uint SlotIndex)`
|
||||
(lines 472-478), `ParseAcceptTrade` (line 483-484+). `GameEventWiring.cs`
|
||||
has no `Register(GameEventType.OpenTrade, ...)` etc. — grepped and only
|
||||
found unrelated chat-room "Trade" comments. So inbound parsing is
|
||||
started but not wired; outbound building doesn't exist at all;
|
||||
`ItemPolicyObject.TradeState` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs:75`)
|
||||
already exists as a field consumed by `DecidePlacement`/`DecideUse`
|
||||
(e.g. "You cannot move an item while it is being traded." at line 354,
|
||||
"You cannot use an item while it is being traded." at line 248) —
|
||||
confirming the POLICY layer already expects a `TradeState` concept even
|
||||
though nothing produces it live yet.
|
||||
|
||||
## 6. Icon rendering for item lists
|
||||
|
||||
Vendor's shop-item rows resolve icons via a bound
|
||||
`Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon` — the same
|
||||
signature that `VendorRuntimeBindings.ResolveIcon`
|
||||
(`RetailUiRuntime.cs:342`) carries, sourced from
|
||||
`iconComposer.GetIcon` (`InteractionRetainedUiComposition.cs:806`).
|
||||
Call site in `VendorUiController`
|
||||
(`src/AcDream.App/UI/Layout/VendorUiController.cs:1090-1106`):
|
||||
```csharp
|
||||
uint icon = _resolveIcon(
|
||||
(ItemType)(item.ItemType ?? 0u),
|
||||
item.IconId,
|
||||
item.IconUnderlayId,
|
||||
item.IconOverlayId,
|
||||
item.Effects);
|
||||
var cell = new UiItemSlot { SpriteResolve = _itemList.SpriteResolve, SlotIndex = ..., AllowDragSource = false };
|
||||
cell.SetItem(item.ItemGuid, icon);
|
||||
```
|
||||
A second call site at `VendorUiController.cs:2199-2203` (shop item, a
|
||||
different list) and a third at `VendorUiController.cs:2240-2241`
|
||||
(`item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId,
|
||||
item.Effects` — a `ClientObject`-sourced variant, for player-owned items
|
||||
being sold) confirm the pattern generalizes across both "vendor stock"
|
||||
and "player inventory" rows — exactly the two sides a trade window
|
||||
needs (local player's staged items + remote player's staged items, both
|
||||
rendered as `UiItemSlot` rows with the same `ResolveIcon` delegate).
|
||||
`UiItemSlot.SetItem(guid, iconSpriteId)` is the shared cell-population
|
||||
call every item list in the codebase uses (vendor, external container,
|
||||
inventory).
|
||||
|
||||
## 7. Test templates
|
||||
|
||||
- **Wire builder test (Core.Net.Tests):**
|
||||
`tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs:1-40+` —
|
||||
`VendorRequestsTests.BuildBuy_SingleItem_...` asserts exact byte
|
||||
offsets (envelope/seq/opcode/args) via `BinaryPrimitives.ReadUInt32LittleEndian`
|
||||
over the returned `byte[]`. A `TradeRequestsTests.cs` would follow this
|
||||
shape per new opcode (OpenTrade/AddToTrade/AcceptTrade/etc).
|
||||
`tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs` is the
|
||||
matching template for the INBOUND parser side (asserting
|
||||
`GameEvents.ParseXxx` against constructed payload bytes).
|
||||
- **Runtime owner test:**
|
||||
`tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs:1-30+`
|
||||
— constructs `GameEvents.FellowMember` fixtures and exercises full-update
|
||||
assembly, incremental upsert, self-vs-other removal, revision
|
||||
monotonicity, ownership convergence. Direct template for a
|
||||
`RuntimeTradeStateTests.cs`.
|
||||
- **Panel controller test with fixtures:**
|
||||
`tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs:1-40+` —
|
||||
a hand-built `ImportedLayout` over `RetailWindowFrame.Mount` for the
|
||||
behavioral suite, PLUS one real-DAT-fixture smoke test (its own doc
|
||||
comment cites `AppraisalUiControllerTests`'s `FixtureLoader` use) that
|
||||
catches drift between hardcoded element ids and the actual LayoutDesc.
|
||||
Direct template for a `TradeUiControllerTests.cs`.
|
||||
|
||||
## Summary of the actionable seam list
|
||||
|
||||
1. `ItemPolicyActionKind.StartSecureTrade` and `.OpenSecureTrade` are
|
||||
both ALREADY produced by the ported retail classifier
|
||||
(drag-onto-player and Use-on-selected-player respectively) — the only
|
||||
gap is execution. Add explicit `case` arms in
|
||||
`ItemInteractionController.ExecuteUseActions` (line ~1072) and
|
||||
`.ExecutePlacementActions` (line ~1160), each invoking a new
|
||||
ctor-injected delegate (mirroring `_sendGive`) rather than falling to
|
||||
the generic `_auxiliaryAction`/`PolicyActionRequested` stub.
|
||||
2. No wire builder exists for any trade opcode — write
|
||||
`src/AcDream.Core.Net/Messages/TradeRequests.cs` (outbound) following
|
||||
`VendorRequests.cs`'s documented-citation shape, and finish
|
||||
`GameEvents.cs`'s partial inbound parsers (3 of ~10 opcodes started)
|
||||
plus register them all in `GameEventWiring.cs` (currently zero trade
|
||||
registrations).
|
||||
3. Add `WorldSession.SendXxx` methods for each trade opcode
|
||||
(`WorldSession.cs`, next to `SendGiveObject`/`SendBuy`).
|
||||
4. New Runtime owner `RuntimeTradeState` — decide nested-under-
|
||||
`RuntimeInventoryState` (Vendor precedent) vs. GameRuntime-level
|
||||
sibling (Fellowship/Allegiance precedent); wire construction in
|
||||
`GameRuntime.cs`, reset in `RuntimeGenerationReset.cs`, inbound
|
||||
routing through `GameEventWiring` → `LiveSessionEventRouter` →
|
||||
`LiveSessionRuntimeFactory.cs:258-273`, and commands through
|
||||
`IGameRuntimeCommands` → `DirectGameRuntimeCommandAdapter` →
|
||||
the new `WorldSession.SendXxx` calls.
|
||||
5. New `TradeRuntimeBindings` record (mirror `VendorRuntimeBindings`,
|
||||
`RetailUiRuntime.cs:340-354`) built in
|
||||
`InteractionRetainedUiComposition.cs` alongside the `Vendor:`/`Social:`
|
||||
blocks, and a `TradeUiController` + `MountSecureTrade()` mounted from
|
||||
`RetailUiRuntime.Initialize()` next to `MountVendor()`
|
||||
(`RetailUiRuntime.cs:480`), reusing `ResolveIcon`/`UiItemSlot.SetItem`
|
||||
for both parties' staged-item rows.
|
||||
|
|
@ -923,7 +923,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
AllegianceBreak: guid => late.GameRuntime.AllegianceBreak(guid),
|
||||
AllegianceKick: guid => late.GameRuntime.AllegianceKick(guid),
|
||||
AllegianceSetUpdateSubscription: on =>
|
||||
late.GameRuntime.AllegianceSetUpdateSubscription(on)),
|
||||
late.GameRuntime.AllegianceSetUpdateSubscription(on),
|
||||
Trade: d.Runtime.Trade),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ internal sealed record LiveSessionCommandBindings(
|
|||
Action<uint> RemoveFriend,
|
||||
Action ClearFriends,
|
||||
Action RequestLegacyFriends,
|
||||
// Secure trade (2026-08-14) — the CM_Trade senders.
|
||||
Action<uint> OpenTradeNegotiations,
|
||||
Action CloseTradeNegotiations,
|
||||
Action<uint> AddToTrade,
|
||||
Action<uint, bool, bool> AcceptTrade,
|
||||
Action DeclineTrade,
|
||||
Action ResetTrade,
|
||||
Action<bool, uint, string, uint> ModifyCharacterSquelch,
|
||||
Action<bool, string> ModifyAccountSquelch,
|
||||
Action<bool, uint> ModifyGlobalSquelch,
|
||||
|
|
@ -92,6 +99,17 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
|
|||
internal readonly record struct SaveCharacterOptionsRuntimeCmd;
|
||||
internal readonly record struct AddFriendRuntimeCmd(string Name);
|
||||
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
|
||||
|
||||
// ── Secure trade (2026-08-14) ──────────────────────────────────────────────
|
||||
internal readonly record struct OpenTradeNegotiationsRuntimeCmd(uint PartnerGuid);
|
||||
internal readonly record struct CloseTradeNegotiationsRuntimeCmd;
|
||||
internal readonly record struct AddToTradeRuntimeCmd(uint ItemGuid);
|
||||
internal readonly record struct AcceptTradeRuntimeCmd(
|
||||
uint PartnerGuid,
|
||||
bool SelfAccepted,
|
||||
bool PartnerAccepted);
|
||||
internal readonly record struct DeclineTradeRuntimeCmd;
|
||||
internal readonly record struct ResetTradeRuntimeCmd;
|
||||
internal readonly record struct ClearFriendsRuntimeCmd;
|
||||
internal readonly record struct RequestLegacyFriendsRuntimeCmd;
|
||||
internal readonly record struct ModifyCharacterSquelchRuntimeCmd(
|
||||
|
|
@ -210,6 +228,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
_ => SendIfActive(bindings.SaveCharacterOptions));
|
||||
commands.Register<AddFriendRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddFriend(command.Name)));
|
||||
commands.Register<OpenTradeNegotiationsRuntimeCmd>(
|
||||
command => SendIfActive(() =>
|
||||
bindings.OpenTradeNegotiations(command.PartnerGuid)));
|
||||
commands.Register<CloseTradeNegotiationsRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.CloseTradeNegotiations));
|
||||
commands.Register<AddToTradeRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid)));
|
||||
commands.Register<AcceptTradeRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AcceptTrade(
|
||||
command.PartnerGuid,
|
||||
command.SelfAccepted,
|
||||
command.PartnerAccepted)));
|
||||
commands.Register<DeclineTradeRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.DeclineTrade));
|
||||
commands.Register<ResetTradeRuntimeCmd>(
|
||||
_ => SendIfActive(bindings.ResetTrade));
|
||||
commands.Register<RemoveFriendRuntimeCmd>(
|
||||
command => SendIfActive(() =>
|
||||
bindings.RemoveFriend(command.CharacterId)));
|
||||
|
|
|
|||
|
|
@ -270,7 +270,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.Communication.Squelch,
|
||||
(text, type) => _domain.Communication.AddText(text, type),
|
||||
Fellowship: _domain.Runtime.FellowshipOwner,
|
||||
Allegiance: _domain.Runtime.AllegianceOwner));
|
||||
Allegiance: _domain.Runtime.AllegianceOwner,
|
||||
Trade: _domain.Runtime.TradeOwner));
|
||||
return new GraphicalSessionEventRoute(
|
||||
route,
|
||||
_domain.Runtime,
|
||||
|
|
@ -596,6 +597,18 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
RemoveFriend: session.SendRemoveFriend,
|
||||
ClearFriends: session.SendClearFriends,
|
||||
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
|
||||
// Secure trade (2026-08-14): AcceptTrade's echoed payload is
|
||||
// ACE-discarded (lane B); the initiator field carries the partner
|
||||
// guid — the only initiator identity ACE itself ever put on the
|
||||
// wire (the RegisterTrade landmine).
|
||||
OpenTradeNegotiations: session.SendOpenTradeNegotiations,
|
||||
CloseTradeNegotiations: session.SendCloseTradeNegotiations,
|
||||
AddToTrade: item => session.SendAddToTrade(item),
|
||||
AcceptTrade: (partner, selfAccepted, partnerAccepted) =>
|
||||
session.SendAcceptTrade(
|
||||
partner, 0d, 0u, partner, selfAccepted, partnerAccepted),
|
||||
DeclineTrade: session.SendDeclineTrade,
|
||||
ResetTrade: session.SendResetTrade,
|
||||
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: session.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,16 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's two secure-trade open paths surface here for the trade UI:
|
||||
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
|
||||
/// (<c>DetermineUseResult @ 0x00588460</c> result 5), non-zero for
|
||||
/// drag-item-onto-player with the DragItemOnPlayerOpensSecureTrade
|
||||
/// option (<c>AttemptPlaceIn3D @ 0x00588600</c>). The subscriber
|
||||
/// (SecureTradeUiController) owns the open/stage sequencing.
|
||||
/// </summary>
|
||||
public event Action<uint, uint>? SecureTradeRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
|
||||
/// panel inserts a waiting projection before the pickup request is sent.
|
||||
|
|
@ -1139,6 +1149,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||
_toast?.Invoke(action.Message);
|
||||
break;
|
||||
case ItemPolicyActionKind.OpenSecureTrade:
|
||||
// Use-on-player (ItemHolder::DetermineUseResult
|
||||
// @ 0x00588460 result 5 → ClientTradeSystem::
|
||||
// AttemptToOpenTradeNegotiations @ 0x0056DEE0). The
|
||||
// action's ObjectId IS the target player.
|
||||
SecureTradeRequested?.Invoke(action.ObjectId, 0u);
|
||||
acted |= SecureTradeRequested is not null;
|
||||
break;
|
||||
default:
|
||||
_auxiliaryAction?.Invoke(action);
|
||||
PolicyActionRequested?.Invoke(action);
|
||||
|
|
@ -1161,6 +1179,13 @@ public sealed class ItemInteractionController : IDisposable
|
|||
{
|
||||
switch (action.Kind)
|
||||
{
|
||||
case ItemPolicyActionKind.StartSecureTrade:
|
||||
// Drag-item-onto-player with DragItemOnPlayerOpensSecureTrade
|
||||
// (ItemHolder::AttemptPlaceIn3D @ 0x00588600's option branch
|
||||
// → ClientTradeSystem::AttemptToTradeItem @ 0x0056DF80).
|
||||
// ObjectId = the dragged item, TargetId = the player.
|
||||
SecureTradeRequested?.Invoke(action.TargetId, action.ObjectId);
|
||||
break;
|
||||
case ItemPolicyActionKind.DropToWorld:
|
||||
TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.DropToWorld,
|
||||
|
|
|
|||
293
src/AcDream.App/UI/Layout/SecureTradeUiController.cs
Normal file
293
src/AcDream.App/UI/Layout/SecureTradeUiController.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the imported retail <c>gmSecureTradeUI</c> layout
|
||||
/// (LayoutDesc <c>0x2100000D</c>, root <c>0x1000007A</c>) to
|
||||
/// <see cref="RuntimeTradeState"/>'s view. No panel geometry is synthesized:
|
||||
/// every control is the authored element.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references (lane A, docs/research/2026-08-14-trade-laneA-ui.md):
|
||||
/// <c>gmSecureTradeUI::PostInit @ 0x004CA160</c> binds exactly these ids;
|
||||
/// <c>ListenToElementMessage @ 0x004CAE80</c> reacts to the Trade button
|
||||
/// (0x10000086), Clear All (0x1000008A), and the close X (0x1000008B);
|
||||
/// <c>RecvNotice_RegisterTrade @ 0x004CA5C0</c> opens the panel. The accept
|
||||
/// presentation follows lane A's recommendation: driven off the owner's own
|
||||
/// accepted booleans (the partner status icon's authored 'Highlight' state;
|
||||
/// the Trade button's Selected latch), not retail's ambiguous literal
|
||||
/// m_state numbers. Open paths (retail's own two):
|
||||
/// <c>ItemHolder::DetermineUseResult @ 0x00588460</c> result 5 (Use on a
|
||||
/// player) and <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>'s
|
||||
/// DragItemOnPlayerOpensSecureTrade branch → both surface here through
|
||||
/// <see cref="RequestSecureTrade"/> (the ItemInteractionController event),
|
||||
/// mirroring <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
|
||||
/// @ 0x0056DEE0</c> / <c>AttemptToTradeItem @ 0x0056DF80</c> — the latter's
|
||||
/// "queue the dragged item until the window registers" is the pending-stage
|
||||
/// latch consumed in <see cref="Tick"/>.
|
||||
/// </remarks>
|
||||
public sealed class SecureTradeUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100000Du;
|
||||
public const uint RootId = 0x1000007Au;
|
||||
public const uint PartnerNameId = 0x1000007Eu;
|
||||
public const uint PartnerStatusId = 0x1000007Fu;
|
||||
public const uint PartnerCountId = 0x10000080u;
|
||||
public const uint PartnerListId = 0x10000081u;
|
||||
public const uint SelfNameId = 0x10000085u;
|
||||
public const uint TradeButtonId = 0x10000086u;
|
||||
public const uint SelfCountId = 0x10000087u;
|
||||
public const uint SelfListId = 0x10000088u;
|
||||
public const uint ClearAllButtonId = 0x1000008Au;
|
||||
public const uint CloseButtonId = 0x1000008Bu;
|
||||
|
||||
/// <summary>The authored partner-status accept cue (probe: element
|
||||
/// 0x1000007F states '', 'Highlight', 'Ghosted').</summary>
|
||||
private const string AcceptedState = "Highlight";
|
||||
|
||||
public sealed record Bindings(
|
||||
IRuntimeTradeView Trade,
|
||||
ClientObjectTable Objects,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Action<uint> OpenTrade,
|
||||
Action CloseTrade,
|
||||
Action<uint> AddToTrade,
|
||||
Action<bool /*selfAccepted*/, bool /*partnerAccepted*/, uint /*partner*/> AcceptTrade,
|
||||
Action DeclineTrade,
|
||||
Action ResetTrade,
|
||||
Action<bool> SetWindowVisible);
|
||||
|
||||
private readonly Bindings _bindings;
|
||||
private readonly UiText? _partnerName;
|
||||
private readonly UiElement? _partnerStatus;
|
||||
private readonly UiText? _partnerCount;
|
||||
private readonly UiItemList? _partnerList;
|
||||
private readonly UiText? _selfCount;
|
||||
private readonly UiItemList? _selfList;
|
||||
private readonly UiButton? _tradeButton;
|
||||
|
||||
private long _lastRevision = long.MinValue;
|
||||
private bool _wasOpen;
|
||||
private uint _pendingPartner;
|
||||
private uint _pendingStageItem;
|
||||
private bool _disposed;
|
||||
|
||||
private SecureTradeUiController(
|
||||
ImportedLayout layout,
|
||||
Bindings bindings)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_partnerName = layout.FindElement(PartnerNameId) as UiText;
|
||||
_partnerStatus = layout.FindElement(PartnerStatusId);
|
||||
_partnerCount = layout.FindElement(PartnerCountId) as UiText;
|
||||
_partnerList = layout.FindElement(PartnerListId) as UiItemList;
|
||||
_selfCount = layout.FindElement(SelfCountId) as UiText;
|
||||
_selfList = layout.FindElement(SelfListId) as UiItemList;
|
||||
_tradeButton = layout.FindElement(TradeButtonId) as UiButton;
|
||||
|
||||
if (_tradeButton is not null)
|
||||
{
|
||||
// Retail's accept TOGGLE: not-yet-accepted click → AcceptTrade;
|
||||
// already-accepted click → DeclineTrade (withdraw). Selected is
|
||||
// seeded from the store each Tick (the CH6a/b mirror discipline).
|
||||
_tradeButton.SuppressSelfToggle = true;
|
||||
_tradeButton.OnClick = () =>
|
||||
{
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
if (!snapshot.IsOpen) return;
|
||||
if (snapshot.SelfAccepted)
|
||||
_bindings.DeclineTrade();
|
||||
else
|
||||
_bindings.AcceptTrade(
|
||||
true, snapshot.PartnerAccepted, snapshot.PartnerGuid);
|
||||
};
|
||||
}
|
||||
if (layout.FindElement(ClearAllButtonId) is UiButton clearAll)
|
||||
clearAll.OnClick = () =>
|
||||
{
|
||||
if (_bindings.Trade.Snapshot.IsOpen) _bindings.ResetTrade();
|
||||
};
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = () =>
|
||||
{
|
||||
if (_bindings.Trade.Snapshot.IsOpen) _bindings.CloseTrade();
|
||||
};
|
||||
|
||||
// Retail registers the drag handler on the SELF grid only
|
||||
// (PostInit @ 0x004CA1F7; drops land only on your own side —
|
||||
// HandleDropRelease's ancestor-chain check). An inventory item
|
||||
// dropped on the grid stages it.
|
||||
_selfList?.RegisterDragHandler(new SelfGridDropHandler(this));
|
||||
|
||||
_bindings.SetWindowVisible(false);
|
||||
}
|
||||
|
||||
public static SecureTradeUiController? Bind(
|
||||
ImportedLayout layout, Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
// The two grids are the panel's load-bearing controls; a layout
|
||||
// missing either cannot present a trade honestly.
|
||||
if (layout.FindElement(SelfListId) is not UiItemList
|
||||
|| layout.FindElement(PartnerListId) is not UiItemList)
|
||||
return null;
|
||||
return new SecureTradeUiController(layout, bindings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two retail open paths (Use-on-player, drag-item-on-player) —
|
||||
/// raised by ItemInteractionController. When a trade with this partner
|
||||
/// is already open, a dragged item stages immediately
|
||||
/// (<c>AttemptToTradeItem</c>'s open-trade branch); otherwise open
|
||||
/// negotiations and latch the item until RegisterTrade arrives.
|
||||
/// </summary>
|
||||
public void RequestSecureTrade(uint partnerGuid, uint itemGuid)
|
||||
{
|
||||
if (_disposed || partnerGuid == 0u) return;
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
if (snapshot.IsOpen && snapshot.PartnerGuid == partnerGuid)
|
||||
{
|
||||
if (itemGuid != 0u) _bindings.AddToTrade(itemGuid);
|
||||
return;
|
||||
}
|
||||
_pendingPartner = partnerGuid;
|
||||
_pendingStageItem = itemGuid;
|
||||
_bindings.OpenTrade(partnerGuid);
|
||||
}
|
||||
|
||||
/// <summary>Applies the latest owner snapshot (revision-gated).</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed) return;
|
||||
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
|
||||
|
||||
if (snapshot.IsOpen && !_wasOpen)
|
||||
{
|
||||
_wasOpen = true;
|
||||
_bindings.SetWindowVisible(true);
|
||||
// AttemptToTradeItem's queued item — stage it now that the
|
||||
// window registered, if the register matched the request.
|
||||
if (_pendingStageItem != 0u
|
||||
&& (_pendingPartner == 0u
|
||||
|| snapshot.PartnerGuid == _pendingPartner))
|
||||
{
|
||||
_bindings.AddToTrade(_pendingStageItem);
|
||||
}
|
||||
_pendingStageItem = 0u;
|
||||
_pendingPartner = 0u;
|
||||
}
|
||||
else if (!snapshot.IsOpen && _wasOpen)
|
||||
{
|
||||
_wasOpen = false;
|
||||
_bindings.SetWindowVisible(false);
|
||||
}
|
||||
|
||||
if (snapshot.Revision == _lastRevision) return;
|
||||
_lastRevision = snapshot.Revision;
|
||||
|
||||
if (_partnerName is not null)
|
||||
{
|
||||
string name = _bindings.Objects.Get(snapshot.PartnerGuid)
|
||||
?.GetAppropriateName() ?? string.Empty;
|
||||
_partnerName.LinesProvider =
|
||||
() => [new UiText.Line(name, Vector4.One)];
|
||||
}
|
||||
// Accept cues: the partner icon's authored Highlight state (the same
|
||||
// ActiveState flip the fellowship row's amber selection uses); the
|
||||
// Trade button's Selected latch for the local player's own accept.
|
||||
if (_partnerStatus is UiDatElement status)
|
||||
status.ActiveState = snapshot.PartnerAccepted ? AcceptedState : "";
|
||||
if (_tradeButton is not null)
|
||||
_tradeButton.Selected = snapshot.SelfAccepted;
|
||||
|
||||
SetCount(_selfCount, snapshot.SelfItemCount);
|
||||
SetCount(_partnerCount, snapshot.PartnerItemCount);
|
||||
Populate(_selfList, RuntimeTradeSide.Self);
|
||||
Populate(_partnerList, RuntimeTradeSide.Partner);
|
||||
}
|
||||
|
||||
public void SyncVisibility()
|
||||
{
|
||||
_wasOpen = !_bindings.Trade.Snapshot.IsOpen; // force re-evaluate
|
||||
Tick();
|
||||
}
|
||||
|
||||
public void OnShown() => Tick();
|
||||
|
||||
private void SetCount(UiText? text, int count)
|
||||
{
|
||||
if (text is null) return;
|
||||
// Numeric-only, the AD-85 numeric-fields disposition: the authored
|
||||
// ID_SecureTrade_TotalItemsLabel template's variable shape is
|
||||
// unverified, so the DATA shows without invented surrounding words.
|
||||
string line = count.ToString();
|
||||
text.LinesProvider = () => [new UiText.Line(line, Vector4.One)];
|
||||
}
|
||||
|
||||
private void Populate(UiItemList? list, RuntimeTradeSide side)
|
||||
{
|
||||
if (list is null) return;
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
foreach (uint guid in _bindings.Trade.GetItems(side))
|
||||
{
|
||||
ClientObject? item = _bindings.Objects.Get(guid);
|
||||
uint icon = item is null ? 0u : _bindings.ResolveIcon(
|
||||
item.Type,
|
||||
item.IconId,
|
||||
item.IconUnderlayId,
|
||||
item.IconOverlayId,
|
||||
item.Effects);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SlotIndex = list.GetNumUIItems(),
|
||||
// Staged rows are not drag sources — ACE has no
|
||||
// per-item removal (only Clear All / reset).
|
||||
AllowDragSource = false,
|
||||
};
|
||||
cell.SetItem(guid, icon);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_tradeButton is not null) _tradeButton.OnClick = null;
|
||||
}
|
||||
|
||||
/// <summary>Drops on the SELF grid stage the dragged inventory item
|
||||
/// (retail's AcceptDragObject → AddToTrade path).</summary>
|
||||
private sealed class SelfGridDropHandler(SecureTradeUiController owner)
|
||||
: IItemListDragHandler
|
||||
{
|
||||
public void OnDragLift(
|
||||
UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
// The trade grids are never drag SOURCES (AllowDragSource=false
|
||||
// on every staged cell) — nothing to lift.
|
||||
}
|
||||
|
||||
public ItemDragAcceptance OnDragOver(
|
||||
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> payload.SourceKind == ItemDragSource.Inventory
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
|
||||
public void HandleDropRelease(
|
||||
UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
{
|
||||
if (payload.SourceKind != ItemDragSource.Inventory) return;
|
||||
if (owner._bindings.Trade.Snapshot.IsOpen)
|
||||
owner._bindings.AddToTrade(payload.ObjId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -283,7 +283,10 @@ public sealed record SocialRuntimeBindings(
|
|||
Func<uint, RuntimeCommandResult> AllegianceSwear,
|
||||
Func<uint, RuntimeCommandResult> AllegianceBreak,
|
||||
Func<uint, RuntimeCommandResult> AllegianceKick,
|
||||
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription);
|
||||
Func<bool, RuntimeCommandResult> AllegianceSetUpdateSubscription,
|
||||
// Secure trade (2026-08-14): the third sibling J-owner's borrowed view.
|
||||
// Trailing/optional per the established compatibility convention.
|
||||
AcDream.Runtime.Gameplay.IRuntimeTradeView? Trade = null);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -478,6 +481,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountInventory();
|
||||
MountExternalContainer();
|
||||
MountVendor();
|
||||
MountSecureTrade();
|
||||
MountItemCooldowns();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
|
|
@ -607,6 +611,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
LinkStatusUiController?.Tick();
|
||||
IndicatorBarController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SecureTradeController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
|
|
@ -3470,6 +3475,102 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
|
||||
}
|
||||
|
||||
/// <summary>The mounted secure-trade window's controller — null until
|
||||
/// <see cref="MountSecureTrade"/> runs (or when the trade view/layout is
|
||||
/// unavailable).</summary>
|
||||
public Layout.SecureTradeUiController? SecureTradeController { get; private set; }
|
||||
|
||||
private void MountSecureTrade()
|
||||
{
|
||||
if (_bindings.Social.Trade is not { } tradeView)
|
||||
{
|
||||
Console.WriteLine("[M4] secure trade: no runtime trade view bound.");
|
||||
return;
|
||||
}
|
||||
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
Layout.SecureTradeUiController.LayoutId,
|
||||
Layout.SecureTradeUiController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[M4] secure trade: LayoutDesc 0x2100000D root 0x1000007A not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var bus = _bindings.Options.CommandBus();
|
||||
Layout.SecureTradeUiController? controller =
|
||||
Layout.SecureTradeUiController.Bind(
|
||||
layout,
|
||||
new Layout.SecureTradeUiController.Bindings(
|
||||
Trade: tradeView,
|
||||
Objects: _bindings.Inventory.Objects,
|
||||
ResolveIcon: _bindings.Inventory.ResolveIcon,
|
||||
OpenTrade: partner => bus.Publish(
|
||||
new OpenTradeNegotiationsRuntimeCmd(partner)),
|
||||
CloseTrade: () => bus.Publish(
|
||||
new CloseTradeNegotiationsRuntimeCmd()),
|
||||
AddToTrade: item => bus.Publish(
|
||||
new AddToTradeRuntimeCmd(item)),
|
||||
AcceptTrade: (selfAccepted, partnerAccepted, partner) =>
|
||||
bus.Publish(new AcceptTradeRuntimeCmd(
|
||||
partner, selfAccepted, partnerAccepted)),
|
||||
DeclineTrade: () => bus.Publish(new DeclineTradeRuntimeCmd()),
|
||||
ResetTrade: () => bus.Publish(new ResetTradeRuntimeCmd()),
|
||||
SetWindowVisible: visible =>
|
||||
{
|
||||
if (visible) Host.ShowWindow(WindowNames.SecureTrade);
|
||||
else Host.HideWindow(WindowNames.SecureTrade);
|
||||
}));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M4] secure trade: required authored grids are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.SecureTrade,
|
||||
// Root 0x1000007A authors content only (the in-screen copy's
|
||||
// bevel lives in LayoutDesc 0x21000005, not here) — the
|
||||
// shared nine-slice frame surrounds it, same as the vendor.
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = MathF.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
|
||||
Top = MathF.Max(0f, (Host.Root.Height - root.Height) * 0.5f),
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
MinWidth = root.Width,
|
||||
MinHeight = root.Height,
|
||||
Visible = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
});
|
||||
|
||||
SecureTradeController = controller;
|
||||
Host.WindowManager.AttachController(WindowNames.SecureTrade, controller);
|
||||
// Both retail open paths (Use-on-player, drag-item-on-player) —
|
||||
// raised by ItemInteractionController's policy execution arms.
|
||||
_bindings.Inventory.ItemInteraction.SecureTradeRequested +=
|
||||
controller.RequestSecureTrade;
|
||||
Console.WriteLine(
|
||||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||
}
|
||||
|
||||
private void MountItemCooldowns()
|
||||
{
|
||||
ItemCooldownAssets? assets;
|
||||
|
|
@ -3506,6 +3607,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_characterSheetSubscription?.Dispose();
|
||||
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
|
||||
WindowOpacity.Dispose();
|
||||
if (SecureTradeController is { } trade)
|
||||
{
|
||||
_bindings.Inventory.ItemInteraction.SecureTradeRequested -=
|
||||
trade.RequestSecureTrade;
|
||||
}
|
||||
},
|
||||
() => _itemConfirmationController?.Dispose(),
|
||||
() => _gameplayConfirmationController?.Dispose(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public static class WindowNames
|
|||
public const string Vitae = "vitae";
|
||||
public const string Examination = "examination";
|
||||
public const string Vendor = "vendor";
|
||||
public const string SecureTrade = "secure-trade";
|
||||
public const string Options = "options";
|
||||
public const string KeyboardConfig = "keyboard-config";
|
||||
|
||||
|
|
|
|||
|
|
@ -112,7 +112,19 @@ public static class GameEventWiring
|
|||
Action<ClientCommandResponses.AllegianceUpdate>? onAllegianceUpdate = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
|
||||
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null)
|
||||
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null,
|
||||
// Secure trade (2026-08-14): the same Runtime-owned delegate-hole
|
||||
// shape as fellowship above. RuntimeTradeState is the consumer;
|
||||
// docs/research/2026-08-14-trade-laneB-wire.md is the wire SSOT.
|
||||
Action<GameEvents.RegisterTrade>? onTradeRegister = null,
|
||||
Action<uint /*endReason*/>? onTradeClose = null,
|
||||
Action<GameEvents.AddToTrade>? onTradeAdd = null,
|
||||
Action<GameEvents.RemoveFromTrade>? onTradeRemove = null,
|
||||
Action<uint /*whoAccepted*/>? onTradeAccept = null,
|
||||
Action<uint /*whoDeclined*/>? onTradeDecline = null,
|
||||
Action<uint /*whoReset*/>? onTradeReset = null,
|
||||
Action<GameEvents.TradeFailure>? onTradeFailure = null,
|
||||
Action? onTradeClearAcceptance = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
|
@ -316,6 +328,80 @@ public static class GameEventWiring
|
|||
});
|
||||
}
|
||||
|
||||
// ── Secure trade (0x01FD–0x0208) ──────────────────────────
|
||||
if (onTradeRegister is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.RegisterTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseRegisterTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeRegister(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeClose is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.CloseTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseCloseTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeClose(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeAdd is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.AddToTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseAddToTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeAdd(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeRemove is not null)
|
||||
{
|
||||
// ACE never emits 0x0201; registered defensively for the retail
|
||||
// handler's sake (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00).
|
||||
registrar.Register(GameEventType.RemoveFromTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseRemoveFromTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeRemove(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeAccept is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.AcceptTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseAcceptTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeAccept(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeDecline is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.DeclineTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseDeclineTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeDecline(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeReset is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.ResetTrade, e =>
|
||||
{
|
||||
var p = GameEvents.ParseResetTrade(e.Payload.Span);
|
||||
if (p is not null) onTradeReset(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeFailure is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.TradeFailure, e =>
|
||||
{
|
||||
var p = GameEvents.ParseTradeFailure(e.Payload.Span);
|
||||
if (p is not null) onTradeFailure(p.Value);
|
||||
});
|
||||
}
|
||||
if (onTradeClearAcceptance is not null)
|
||||
{
|
||||
// 0x0208 carries no payload (GameEventClearTradeAcceptance).
|
||||
registrar.Register(GameEventType.ClearTradeAcceptance, _ =>
|
||||
onTradeClearAcceptance());
|
||||
}
|
||||
|
||||
if (onConfirmationRequest is not null)
|
||||
{
|
||||
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
|
||||
|
|
|
|||
|
|
@ -462,31 +462,98 @@ public static class GameEvents
|
|||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0207 TradeFailure: server trade error code.</summary>
|
||||
public static uint? ParseTradeFailure(ReadOnlySpan<byte> payload)
|
||||
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
|
||||
// ACE writers + retail parsers agree on every field below; retail
|
||||
// dispatch addresses cited per event.
|
||||
|
||||
/// <summary>0x01FD RegisterTrade: (initiator, partner, stamp). Retail
|
||||
/// <c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>. ACE landmine:
|
||||
/// BOTH sides receive initiator == partner == the non-self player's guid
|
||||
/// (never the true initiator) and stamp is always 0 — consumers must
|
||||
/// derive "who opened" themselves (lane B §quirks).</summary>
|
||||
public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp);
|
||||
|
||||
public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 16) return null;
|
||||
return new RegisterTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8)));
|
||||
}
|
||||
|
||||
/// <summary>0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2,
|
||||
/// Canceled=0x51). Retail dispatch @ 0x006ACE90.</summary>
|
||||
public static uint? ParseCloseTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0200 AddToTrade: (itemGuid, slotIndex).</summary>
|
||||
public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex);
|
||||
/// <summary>0x0200 AddToTrade: (itemGuid, side, slot). Side: 1 = the
|
||||
/// receiving client's own offer, 2 = the partner's. Slot is always 0
|
||||
/// from ACE. Retail dispatch @ 0x006ACE20 reads three dwords.</summary>
|
||||
public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex);
|
||||
|
||||
public static AddToTrade? ParseAddToTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
if (payload.Length < 12) return null;
|
||||
return new AddToTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
|
||||
}
|
||||
|
||||
/// <summary>0x0201 RemoveFromTrade: (itemGuid, mode). Retail
|
||||
/// <c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>; ACE never
|
||||
/// emits it (no per-item removal server-side) — parsed defensively.</summary>
|
||||
public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode);
|
||||
|
||||
public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new RemoveFromTrade(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>0x0202 AcceptTrade: initiator guid.</summary>
|
||||
/// <summary>0x0202 AcceptTrade: who accepted (the client compares
|
||||
/// against its own guid for self-vs-partner — retail dispatch
|
||||
/// @ 0x006ACDF0).</summary>
|
||||
public static uint? ParseAcceptTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0203 DeclineTrade: who declined.</summary>
|
||||
public static uint? ParseDeclineTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0205 ResetTrade: who reset. ACE clears BOTH sides'
|
||||
/// staged items on either player's reset (lane B §quirks).</summary>
|
||||
public static uint? ParseResetTrade(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail
|
||||
/// <c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c> removes the item
|
||||
/// locally before showing the notice.</summary>
|
||||
public readonly record struct TradeFailure(uint ItemGuid, uint Reason);
|
||||
|
||||
public static TradeFailure? ParseTradeFailure(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
return new TradeFailure(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
|
||||
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
|
||||
|
|
|
|||
110
src/AcDream.Core.Net/Messages/TradeRequests.cs
Normal file
110
src/AcDream.Core.Net/Messages/TradeRequests.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Secure-trade GameAction builders — retail's <c>CM_Trade</c> senders,
|
||||
/// byte-checked against ACE's readers (the server acdream runs against).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire research: <c>docs/research/2026-08-14-trade-laneB-wire.md</c> —
|
||||
/// ACE, the retail decomp, and holtburger's independent Rust implementation
|
||||
/// agree on every field ACE implements. Retail senders:
|
||||
/// <c>Event_OpenTradeNegotiations @ 0x0056D300</c>,
|
||||
/// <c>Event_CloseTradeNegotiations @ 0x0056D1E0</c>,
|
||||
/// <c>Event_AddToTrade @ 0x0056D0D0</c>, <c>Event_AcceptTrade</c> (packing
|
||||
/// <c>Trade::Pack @ 0x005B9FF0</c>), <c>Event_DeclineTrade @ 0x0056D270</c>,
|
||||
/// <c>Event_ResetTrade @ 0x0056D3D0</c>. There is NO RemoveFromTrade
|
||||
/// C→S action — retail's per-item removal is client-local; ACE's only
|
||||
/// clear is the full-window ResetTrade (which clears BOTH sides).
|
||||
/// </remarks>
|
||||
public static class TradeRequests
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint OpenTradeNegotiationsOpcode = 0x01F6u;
|
||||
public const uint CloseTradeNegotiationsOpcode = 0x01F7u;
|
||||
public const uint AddToTradeOpcode = 0x01F8u;
|
||||
public const uint AcceptTradeOpcode = 0x01FAu;
|
||||
public const uint DeclineTradeOpcode = 0x01FBu;
|
||||
public const uint ResetTradeOpcode = 0x0204u;
|
||||
|
||||
/// <summary>Open trade with <paramref name="partnerGuid"/> — ACE walks
|
||||
/// the initiator into range (<c>CreateMoveToChain</c>) before both sides
|
||||
/// receive RegisterTrade.</summary>
|
||||
public static byte[] BuildOpenTradeNegotiations(
|
||||
uint gameActionSequence, uint partnerGuid)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
WriteHeader(body, gameActionSequence, OpenTradeNegotiationsOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] BuildCloseTradeNegotiations(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, CloseTradeNegotiationsOpcode);
|
||||
|
||||
/// <summary>Stage an item. <paramref name="tradeSlot"/> is ACE-ignored
|
||||
/// (it always echoes slot 0); retail sends its grid slot.</summary>
|
||||
public static byte[] BuildAddToTrade(
|
||||
uint gameActionSequence, uint itemGuid, uint tradeSlot = 0u)
|
||||
{
|
||||
byte[] body = new byte[20];
|
||||
WriteHeader(body, gameActionSequence, AddToTradeOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), tradeSlot);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accept the current offer. The payload mirrors retail's
|
||||
/// <c>Trade::Pack</c> fixed fields; ACE parses and then DISCARDS every
|
||||
/// one of them (<c>HandleActionAcceptTrade()</c> takes zero arguments —
|
||||
/// server state is fully self-derived), so the two trailing
|
||||
/// PackableList<ContentProfile> item lists retail appends are sent
|
||||
/// as zero-count lists here (register row AD-94).
|
||||
/// </summary>
|
||||
public static byte[] BuildAcceptTrade(
|
||||
uint gameActionSequence,
|
||||
uint partnerGuid,
|
||||
double tradeStamp,
|
||||
uint tradeStatus,
|
||||
uint initiatorGuid,
|
||||
bool initiatorAccepts,
|
||||
bool partnerAccepts)
|
||||
{
|
||||
byte[] body = new byte[48];
|
||||
WriteHeader(body, gameActionSequence, AcceptTradeOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(body.AsSpan(16), tradeStamp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), tradeStatus);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(28), initiatorGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(32), initiatorAccepts ? 1u : 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(36), partnerAccepts ? 1u : 0u);
|
||||
// Two zero-count item lists (see remarks / AD-94).
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(40), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(44), 0u);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static byte[] BuildDeclineTrade(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, DeclineTradeOpcode);
|
||||
|
||||
/// <summary>Clear the trade window. ACE clears BOTH players' staged
|
||||
/// items, not just the sender's (Player_Trade landmine — lane B §quirks).</summary>
|
||||
public static byte[] BuildResetTrade(uint gameActionSequence)
|
||||
=> BuildEmpty(gameActionSequence, ResetTradeOpcode);
|
||||
|
||||
private static byte[] BuildEmpty(uint gameActionSequence, uint opcode)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
WriteHeader(body, gameActionSequence, opcode);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static void WriteHeader(byte[] body, uint sequence, uint opcode)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
|
||||
}
|
||||
}
|
||||
|
|
@ -2561,6 +2561,65 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
|
||||
}
|
||||
|
||||
// ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ────────
|
||||
|
||||
/// <summary>Open secure trade with another player — retail
|
||||
/// <c>CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300</c>.</summary>
|
||||
public void SendOpenTradeNegotiations(uint partnerGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid));
|
||||
}
|
||||
|
||||
/// <summary>Close the trade window — <c>Event_CloseTradeNegotiations
|
||||
/// @ 0x0056D1E0</c>.</summary>
|
||||
public void SendCloseTradeNegotiations()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq));
|
||||
}
|
||||
|
||||
/// <summary>Stage an item into the trade — <c>Event_AddToTrade
|
||||
/// @ 0x0056D0D0</c>.</summary>
|
||||
public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot));
|
||||
}
|
||||
|
||||
/// <summary>Accept the current offer — <c>Event_AcceptTrade</c> packing
|
||||
/// <c>Trade::Pack @ 0x005B9FF0</c>'s fixed fields (ACE discards the
|
||||
/// payload entirely; see TradeRequests.BuildAcceptTrade).</summary>
|
||||
public void SendAcceptTrade(
|
||||
uint partnerGuid,
|
||||
double tradeStamp,
|
||||
uint tradeStatus,
|
||||
uint initiatorGuid,
|
||||
bool initiatorAccepts,
|
||||
bool partnerAccepts)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildAcceptTrade(
|
||||
seq, partnerGuid, tradeStamp, tradeStatus,
|
||||
initiatorGuid, initiatorAccepts, partnerAccepts));
|
||||
}
|
||||
|
||||
/// <summary>Withdraw a previous accept — <c>Event_DeclineTrade
|
||||
/// @ 0x0056D270</c>.</summary>
|
||||
public void SendDeclineTrade()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildDeclineTrade(seq));
|
||||
}
|
||||
|
||||
/// <summary>Clear the trade window — <c>Event_ResetTrade @ 0x0056D3D0</c>.
|
||||
/// ACE clears BOTH sides' staged items (lane B §quirks).</summary>
|
||||
public void SendResetTrade()
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(TradeRequests.BuildResetTrade(seq));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail GiveObjectRequest (0x00CD). Retail
|
||||
/// <c>CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00</c> writes
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ public enum GameRuntimeTeardownStage
|
|||
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
||||
FellowshipDisposed = 1 << 9,
|
||||
AllegianceDisposed = 1 << 10,
|
||||
IdentityDisposed = 1 << 11,
|
||||
EntityObjectsDisposed = 1 << 12,
|
||||
// Secure trade (2026-08-14): third sibling J-owner, same shape.
|
||||
TradeDisposed = 1 << 11,
|
||||
IdentityDisposed = 1 << 12,
|
||||
EntityObjectsDisposed = 1 << 13,
|
||||
Complete =
|
||||
HostLeasesReleased
|
||||
| EventsDetached
|
||||
|
|
@ -51,6 +53,7 @@ public enum GameRuntimeTeardownStage
|
|||
| CommunicationDisposed
|
||||
| FellowshipDisposed
|
||||
| AllegianceDisposed
|
||||
| TradeDisposed
|
||||
| IdentityDisposed
|
||||
| EntityObjectsDisposed,
|
||||
}
|
||||
|
|
@ -94,6 +97,7 @@ internal enum GameRuntimeConstructionPoint
|
|||
CommunicationCreated,
|
||||
FellowshipCreated,
|
||||
AllegianceCreated,
|
||||
TradeCreated,
|
||||
MovementCreated,
|
||||
ActionsCreated,
|
||||
EnvironmentCreated,
|
||||
|
|
@ -111,6 +115,7 @@ internal sealed class GameRuntimeConstructionContext
|
|||
public RuntimeCommunicationState? Communication { get; set; }
|
||||
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||
public RuntimeTradeState? Trade { get; set; }
|
||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||
public RuntimeActionState? Actions { get; set; }
|
||||
public GameRuntimeEventHub? Events { get; set; }
|
||||
|
|
@ -126,7 +131,7 @@ public sealed class GameRuntime
|
|||
IRuntimeEventSource,
|
||||
IDisposable
|
||||
{
|
||||
private const int TeardownStageCount = 13;
|
||||
private const int TeardownStageCount = 14;
|
||||
|
||||
private readonly object _lifetimeGate = new();
|
||||
private readonly Dictionary<long, string> _hostLeases = [];
|
||||
|
|
@ -248,6 +253,16 @@ public sealed class GameRuntime
|
|||
context,
|
||||
faultInjection);
|
||||
|
||||
// Secure trade (2026-08-14): third sibling J-owner —
|
||||
// session-scoped like fellowship (a disconnect closes the trade
|
||||
// server-side), clears at every generation reset.
|
||||
context.Trade = new RuntimeTradeState();
|
||||
construction.Own(context.Trade);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.TradeCreated,
|
||||
context,
|
||||
faultInjection);
|
||||
|
||||
context.Movement = new RuntimeLocalPlayerMovementState();
|
||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||
|
|
@ -302,7 +317,8 @@ public sealed class GameRuntime
|
|||
context.Character,
|
||||
context.PlayerIdentity,
|
||||
context.Fellowship,
|
||||
context.Allegiance);
|
||||
context.Allegiance,
|
||||
context.Trade);
|
||||
|
||||
context.Movement.AttachPhysicsPublication(
|
||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
|
|
@ -357,6 +373,7 @@ public sealed class GameRuntime
|
|||
CommunicationOwner = context.Communication;
|
||||
FellowshipOwner = context.Fellowship;
|
||||
AllegianceOwner = context.Allegiance;
|
||||
TradeOwner = context.Trade;
|
||||
MovementOwner = context.Movement;
|
||||
ActionOwner = context.Actions;
|
||||
EnvironmentOwner = environment;
|
||||
|
|
@ -462,6 +479,9 @@ public sealed class GameRuntime
|
|||
public RuntimeCommunicationState CommunicationOwner { get; }
|
||||
public RuntimeFellowshipState FellowshipOwner { get; }
|
||||
public RuntimeAllegianceState AllegianceOwner { get; }
|
||||
|
||||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||
public RuntimeTradeState TradeOwner { get; }
|
||||
public RuntimeActionState ActionOwner { get; }
|
||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||
internal RuntimeLocalPlayerPhysicsPublicationState
|
||||
|
|
@ -507,6 +527,8 @@ public sealed class GameRuntime
|
|||
public IRuntimeChatView Chat => CommunicationOwner.View;
|
||||
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
||||
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
||||
|
||||
public IRuntimeTradeView Trade => TradeOwner.View;
|
||||
public IRuntimeActionView Actions => ActionOwner.View;
|
||||
public IRuntimeMovementView Movement => MovementOwner.View;
|
||||
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
||||
|
|
@ -607,7 +629,8 @@ public sealed class GameRuntime
|
|||
ActionOwner,
|
||||
MovementOwner,
|
||||
FellowshipOwner,
|
||||
AllegianceOwner),
|
||||
AllegianceOwner,
|
||||
TradeOwner),
|
||||
EnvironmentOwner.CaptureOwnership(),
|
||||
TransitOwner.CaptureOwnership(),
|
||||
GenerationReset.CaptureSnapshot(),
|
||||
|
|
@ -733,16 +756,22 @@ public sealed class GameRuntime
|
|||
9 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.FellowshipDisposed
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
10 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
11 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
12 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
13 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
_ => GameRuntimeTeardownStage.Complete,
|
||||
};
|
||||
|
|
@ -794,9 +823,12 @@ public sealed class GameRuntime
|
|||
AllegianceOwner.Dispose();
|
||||
return AllegianceOwner.CaptureOwnership().IsConverged;
|
||||
case 11:
|
||||
TradeOwner.Dispose();
|
||||
return TradeOwner.CaptureOwnership().IsConverged;
|
||||
case 12:
|
||||
PlayerIdentity.Dispose();
|
||||
return PlayerIdentity.CaptureOwnership().IsConverged;
|
||||
case 12:
|
||||
case 13:
|
||||
EntityObjects.Dispose();
|
||||
return EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
||||
|
|
@ -819,8 +851,9 @@ public sealed class GameRuntime
|
|||
8 => CommunicationOwner.CaptureOwnership().IsConverged,
|
||||
9 => FellowshipOwner.CaptureOwnership().IsConverged,
|
||||
10 => AllegianceOwner.CaptureOwnership().IsConverged,
|
||||
11 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
12 => EntityObjects.CaptureOwnership().IsConverged
|
||||
11 => TradeOwner.CaptureOwnership().IsConverged,
|
||||
12 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
13 => EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
||||
_ => true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
|||
RuntimeCommunicationOwnershipSnapshot Communication,
|
||||
RuntimeActionOwnershipSnapshot Actions,
|
||||
RuntimeLocalMovementOwnershipSnapshot Movement,
|
||||
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
||||
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners;
|
||||
// secure trade (2026-08-14) is the third.
|
||||
RuntimeFellowshipOwnershipSnapshot Fellowship,
|
||||
RuntimeAllegianceOwnershipSnapshot Allegiance)
|
||||
RuntimeAllegianceOwnershipSnapshot Allegiance,
|
||||
RuntimeTradeOwnershipSnapshot Trade)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
Inventory.IsConverged
|
||||
|
|
@ -22,7 +24,8 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
|||
&& Actions.IsConverged
|
||||
&& Movement.IsConverged
|
||||
&& Fellowship.IsConverged
|
||||
&& Allegiance.IsConverged;
|
||||
&& Allegiance.IsConverged
|
||||
&& Trade.IsConverged;
|
||||
}
|
||||
|
||||
public static class RuntimeGameplayOwnership
|
||||
|
|
@ -34,7 +37,8 @@ public static class RuntimeGameplayOwnership
|
|||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
|
|
@ -43,6 +47,7 @@ public static class RuntimeGameplayOwnership
|
|||
ArgumentNullException.ThrowIfNull(movement);
|
||||
ArgumentNullException.ThrowIfNull(fellowship);
|
||||
ArgumentNullException.ThrowIfNull(allegiance);
|
||||
ArgumentNullException.ThrowIfNull(trade);
|
||||
return new RuntimeGameplayOwnershipSnapshot(
|
||||
inventory.CaptureOwnership(),
|
||||
character.CaptureOwnership(),
|
||||
|
|
@ -50,6 +55,7 @@ public static class RuntimeGameplayOwnership
|
|||
actions.CaptureOwnership(),
|
||||
movement.CaptureOwnership(),
|
||||
fellowship.CaptureOwnership(),
|
||||
allegiance.CaptureOwnership());
|
||||
allegiance.CaptureOwnership(),
|
||||
trade.CaptureOwnership());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
307
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
Normal file
307
src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeTradeOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool IsOpen,
|
||||
int StagedItemCount)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& !IsOpen
|
||||
&& StagedItemCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>One player's staged-item side of the trade window.</summary>
|
||||
public enum RuntimeTradeSide : uint
|
||||
{
|
||||
Self = 1u,
|
||||
Partner = 2u,
|
||||
}
|
||||
|
||||
/// <summary>Immutable poll snapshot of the whole trade.</summary>
|
||||
public readonly record struct RuntimeTradeSnapshot(
|
||||
long Revision,
|
||||
bool IsOpen,
|
||||
uint PartnerGuid,
|
||||
bool SelfAccepted,
|
||||
bool PartnerAccepted,
|
||||
int SelfItemCount,
|
||||
int PartnerItemCount,
|
||||
uint LastFailureItemGuid,
|
||||
uint LastFailureReason);
|
||||
|
||||
public interface IRuntimeTradeView
|
||||
{
|
||||
RuntimeTradeSnapshot Snapshot { get; }
|
||||
|
||||
/// <summary>Materialized staged-item guids for one side, in stage order.</summary>
|
||||
IReadOnlyList<uint> GetItems(RuntimeTradeSide side);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for the secure-trade window —
|
||||
/// retail's <c>ClientTradeSystem</c>/<c>Trade</c> state, session-scoped like
|
||||
/// <see cref="RuntimeFellowshipState"/> (a disconnect closes the trade
|
||||
/// server-side, so this clears at every generation reset).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire SSOT: <c>docs/research/2026-08-14-trade-laneB-wire.md</c>; retail UI
|
||||
/// truth: <c>docs/research/2026-08-14-trade-laneA-ui.md</c>. Assembled from
|
||||
/// the 0x01FD–0x0208 event family. ACE landmines honored here:
|
||||
/// RegisterTrade's initiator/partner fields BOTH carry the non-self player's
|
||||
/// guid (the true initiator is never on the wire — <c>Player_Trade.cs:80,98</c>),
|
||||
/// so <see cref="ApplyRegister"/> derives the partner as "whichever guid is
|
||||
/// not mine, else either"; ResetTrade clears BOTH sides' items regardless of
|
||||
/// who reset. Consumers poll <see cref="View"/>'s monotonic revision — no
|
||||
/// push event, the same D2 discipline as fellowship.
|
||||
/// </remarks>
|
||||
public sealed class RuntimeTradeState : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<uint> _selfItems = [];
|
||||
private readonly List<uint> _partnerItems = [];
|
||||
private bool _isOpen;
|
||||
private uint _partnerGuid;
|
||||
private bool _selfAccepted;
|
||||
private bool _partnerAccepted;
|
||||
private uint _lastFailureItemGuid;
|
||||
private uint _lastFailureReason;
|
||||
private long _revision;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeTradeState() => View = new TradeView(this);
|
||||
|
||||
public IRuntimeTradeView View { get; }
|
||||
|
||||
public bool IsDisposed
|
||||
{
|
||||
get { lock (_gate) return _disposed; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0x01FD RegisterTrade — the window opens on BOTH clients
|
||||
/// (<c>Handle_Trade__Recv_RegisterTrade @ 0x0056E050</c>). Clears any
|
||||
/// stale staged state from a previous trade.
|
||||
/// </summary>
|
||||
public void ApplyRegister(GameEvents.RegisterTrade update, uint selfGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_isOpen = true;
|
||||
_partnerGuid = update.Initiator != selfGuid && update.Initiator != 0u
|
||||
? update.Initiator
|
||||
: update.Partner;
|
||||
_selfItems.Clear();
|
||||
_partnerItems.Clear();
|
||||
_selfAccepted = false;
|
||||
_partnerAccepted = false;
|
||||
_lastFailureItemGuid = 0u;
|
||||
_lastFailureReason = 0u;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x01FF CloseTrade — full teardown on either side's close
|
||||
/// (reason recorded nowhere; retail closes the panel outright).</summary>
|
||||
public void ApplyClose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ClearLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0200 AddToTrade — Side 1 = this client's own offer echo,
|
||||
/// Side 2 = the partner staged an item. ACE's slot is always 0; stage
|
||||
/// order is arrival order (retail's grid does the same against ACE).</summary>
|
||||
public void ApplyAdd(GameEvents.AddToTrade update)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
List<uint> items = update.Side == (uint)RuntimeTradeSide.Partner
|
||||
? _partnerItems
|
||||
: _selfItems;
|
||||
if (!items.Contains(update.ItemGuid))
|
||||
items.Add(update.ItemGuid);
|
||||
// Staging changes invalidate prior acceptance server-side (ACE
|
||||
// re-arms via ClearTradeAcceptance; mirrored defensively here so
|
||||
// a dropped 0x0208 cannot leave a stale green check).
|
||||
_selfAccepted = false;
|
||||
_partnerAccepted = false;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0201 RemoveFromTrade — ACE never emits it; honored
|
||||
/// defensively for the retail handler's shape
|
||||
/// (<c>Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00</c>).</summary>
|
||||
public void ApplyRemove(GameEvents.RemoveFromTrade update)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
bool removed = _selfItems.Remove(update.ItemGuid);
|
||||
removed |= _partnerItems.Remove(update.ItemGuid);
|
||||
if (removed) Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0202 AcceptTrade — who accepted; compared against the
|
||||
/// local player exactly like retail's dispatch @ 0x006ACDF0.</summary>
|
||||
public void ApplyAccept(uint whoAccepted, uint selfGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
if (whoAccepted == selfGuid) _selfAccepted = true;
|
||||
else _partnerAccepted = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0203 DeclineTrade — withdraws that side's acceptance.</summary>
|
||||
public void ApplyDecline(uint whoDeclined, uint selfGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
if (whoDeclined == selfGuid) _selfAccepted = false;
|
||||
else _partnerAccepted = false;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0205 ResetTrade — ACE clears BOTH sides' staged items on
|
||||
/// either player's reset (lane B §quirks), and acceptance with them.
|
||||
/// The window stays open (a completed trade auto-resets this way).</summary>
|
||||
public void ApplyReset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
_selfItems.Clear();
|
||||
_partnerItems.Clear();
|
||||
_selfAccepted = false;
|
||||
_partnerAccepted = false;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0207 TradeFailure — retail removes the refused item
|
||||
/// locally before showing the notice
|
||||
/// (<c>Handle_Trade__Recv_TradeFailure @ 0x0056D990</c>).</summary>
|
||||
public void ApplyFailure(GameEvents.TradeFailure failure)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
_selfItems.Remove(failure.ItemGuid);
|
||||
_partnerItems.Remove(failure.ItemGuid);
|
||||
_lastFailureItemGuid = failure.ItemGuid;
|
||||
_lastFailureReason = failure.Reason;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x0208 ClearTradeAcceptance — both checks come down.</summary>
|
||||
public void ApplyClearAcceptance()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_isOpen) return;
|
||||
_selfAccepted = false;
|
||||
_partnerAccepted = false;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generation reset / session teardown — full clear.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
ClearLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeTradeOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
lock (_gate)
|
||||
return new RuntimeTradeOwnershipSnapshot(
|
||||
_disposed,
|
||||
_isOpen,
|
||||
_selfItems.Count + _partnerItems.Count);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
ClearLocked();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLocked()
|
||||
{
|
||||
bool changed = _isOpen
|
||||
|| _selfItems.Count != 0
|
||||
|| _partnerItems.Count != 0
|
||||
|| _selfAccepted
|
||||
|| _partnerAccepted;
|
||||
_isOpen = false;
|
||||
_partnerGuid = 0u;
|
||||
_selfItems.Clear();
|
||||
_partnerItems.Clear();
|
||||
_selfAccepted = false;
|
||||
_partnerAccepted = false;
|
||||
_lastFailureItemGuid = 0u;
|
||||
_lastFailureReason = 0u;
|
||||
if (changed) Bump();
|
||||
}
|
||||
|
||||
private void Bump() => _revision++;
|
||||
|
||||
private sealed class TradeView(RuntimeTradeState owner) : IRuntimeTradeView
|
||||
{
|
||||
public RuntimeTradeSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (owner._gate)
|
||||
return new RuntimeTradeSnapshot(
|
||||
owner._revision,
|
||||
owner._isOpen,
|
||||
owner._partnerGuid,
|
||||
owner._selfAccepted,
|
||||
owner._partnerAccepted,
|
||||
owner._selfItems.Count,
|
||||
owner._partnerItems.Count,
|
||||
owner._lastFailureItemGuid,
|
||||
owner._lastFailureReason);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> GetItems(RuntimeTradeSide side)
|
||||
{
|
||||
lock (owner._gate)
|
||||
return side == RuntimeTradeSide.Partner
|
||||
? [.. owner._partnerItems]
|
||||
: [.. owner._selfItems];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,15 +53,22 @@ public enum RuntimeGenerationResetStage
|
|||
/// from the precedent it named).
|
||||
/// </summary>
|
||||
Allegiance = 13,
|
||||
BeginEntityRetirement = 14,
|
||||
RetireEntities = 15,
|
||||
DrainHostProjection = 16,
|
||||
CompleteCanonicalEntities = 17,
|
||||
CompleteHostProjection = 18,
|
||||
ChatIdentity = 19,
|
||||
PlayerSnapshots = 20,
|
||||
PlayerIdentity = 21,
|
||||
Complete = 22,
|
||||
/// <summary>
|
||||
/// Secure trade (2026-08-14): the trade window is session-scoped — a
|
||||
/// disconnect closes the trade server-side (ACE tears the negotiation
|
||||
/// down with the session), so the third sibling J-owner clears here
|
||||
/// beside its fellowship/allegiance precedents.
|
||||
/// </summary>
|
||||
Trade = 14,
|
||||
BeginEntityRetirement = 15,
|
||||
RetireEntities = 16,
|
||||
DrainHostProjection = 17,
|
||||
CompleteCanonicalEntities = 18,
|
||||
CompleteHostProjection = 19,
|
||||
ChatIdentity = 20,
|
||||
PlayerSnapshots = 21,
|
||||
PlayerIdentity = 22,
|
||||
Complete = 23,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||
|
|
@ -111,6 +118,7 @@ public sealed class RuntimeGenerationReset
|
|||
private readonly RuntimeLocalPlayerIdentityState _identity;
|
||||
private readonly RuntimeFellowshipState _fellowship;
|
||||
private readonly RuntimeAllegianceState _allegiance;
|
||||
private readonly RuntimeTradeState _trade;
|
||||
private ResetState? _state;
|
||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||
private bool _hasCompletedGeneration;
|
||||
|
|
@ -127,7 +135,8 @@ public sealed class RuntimeGenerationReset
|
|||
RuntimeCharacterState character,
|
||||
RuntimeLocalPlayerIdentityState identity,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_communication = communication
|
||||
|
|
@ -147,6 +156,7 @@ public sealed class RuntimeGenerationReset
|
|||
?? throw new ArgumentNullException(nameof(fellowship));
|
||||
_allegiance = allegiance
|
||||
?? throw new ArgumentNullException(nameof(allegiance));
|
||||
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
|
||||
}
|
||||
|
||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||
|
|
@ -320,6 +330,9 @@ public sealed class RuntimeGenerationReset
|
|||
case RuntimeGenerationResetStage.Allegiance:
|
||||
Advance(state, _allegiance.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.Trade:
|
||||
Advance(state, _trade.Clear);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||
_ = _entityObjects.BeginSessionClear();
|
||||
state.Retirements = _entityObjects
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ public static class RuntimeSimulationOwnership
|
|||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entityObjects);
|
||||
return new RuntimeSimulationOwnershipSnapshot(
|
||||
|
|
@ -43,6 +44,7 @@ public static class RuntimeSimulationOwnership
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance));
|
||||
allegiance,
|
||||
trade));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,10 @@ public sealed record LiveSocialSessionBindings(
|
|||
// compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md
|
||||
// §2.4 — "the established compatibility convention").
|
||||
RuntimeFellowshipState? Fellowship = null,
|
||||
RuntimeAllegianceState? Allegiance = null);
|
||||
RuntimeAllegianceState? Allegiance = null,
|
||||
// Secure trade (2026-08-14): the third sibling J-owner, same
|
||||
// trailing/optional compatibility convention.
|
||||
RuntimeTradeState? Trade = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
|
|
@ -281,6 +284,36 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
? notice => allegianceLogin.ApplyLoginNotification(
|
||||
notice.CharacterGuid,
|
||||
notice.IsLoggedIn)
|
||||
: null,
|
||||
// Secure trade (2026-08-14): same conditional delegate-hole
|
||||
// discipline. Self-vs-partner comparisons use the exact
|
||||
// player guid the fellowship holes above already borrow.
|
||||
onTradeRegister: social.Trade is { } tradeRegister
|
||||
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeClose: social.Trade is { } tradeClose
|
||||
? _ => tradeClose.ApplyClose()
|
||||
: null,
|
||||
onTradeAdd: social.Trade is { } tradeAdd
|
||||
? tradeAdd.ApplyAdd
|
||||
: null,
|
||||
onTradeRemove: social.Trade is { } tradeRemove
|
||||
? tradeRemove.ApplyRemove
|
||||
: null,
|
||||
onTradeAccept: social.Trade is { } tradeAccept
|
||||
? whoAccepted => tradeAccept.ApplyAccept(whoAccepted, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeDecline: social.Trade is { } tradeDecline
|
||||
? whoDeclined => tradeDecline.ApplyDecline(whoDeclined, inventory.PlayerGuid())
|
||||
: null,
|
||||
onTradeReset: social.Trade is { } tradeReset
|
||||
? _ => tradeReset.ApplyReset()
|
||||
: null,
|
||||
onTradeFailure: social.Trade is { } tradeFailure
|
||||
? tradeFailure.ApplyFailure
|
||||
: null,
|
||||
onTradeClearAcceptance: social.Trade is { } tradeClear
|
||||
? tradeClear.ApplyClearAcceptance
|
||||
: null));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,38 @@ public sealed class PowerbarLayoutProbeTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>2026-08-14 trade research: locate the LayoutDesc that authors
|
||||
/// gmSecureTradeUI's element tree (lane A gave element ids 0x10000085-8B
|
||||
/// but not the layout id) and dump it.</summary>
|
||||
[Fact]
|
||||
public void ProbeSecureTradeLayout()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1")
|
||||
return;
|
||||
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var strings = new DatStringResolver(dats);
|
||||
|
||||
int scanned = 0;
|
||||
for (uint layoutId = 0x21000000u; layoutId <= 0x210001FFu; layoutId++)
|
||||
{
|
||||
ElementInfo? root = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
if (root is null)
|
||||
continue;
|
||||
scanned++;
|
||||
if (FindById(root, 0x10000088u) is null)
|
||||
continue;
|
||||
Console.WriteLine($"[pbprobe] SECURE TRADE LAYOUT = 0x{layoutId:X8}");
|
||||
DumpElement(strings, root, 0);
|
||||
}
|
||||
Console.WriteLine($"[pbprobe] scanned {scanned} layouts");
|
||||
}
|
||||
|
||||
private static ElementInfo? FindById(ElementInfo element, uint id)
|
||||
{
|
||||
if (element.Id == id) return element;
|
||||
|
|
|
|||
143
tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs
Normal file
143
tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte coverage for the secure-trade GameAction builders and inbound
|
||||
/// parsers — the 2026-08-14 lane B wire tables (ACE + retail decomp +
|
||||
/// holtburger three-way agreement).
|
||||
/// </summary>
|
||||
public sealed class TradeRequestsTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildOpenTradeNegotiations_WritesEnvelopeSequenceOpcodePartner()
|
||||
{
|
||||
byte[] body = TradeRequests.BuildOpenTradeNegotiations(7, 0x50001234u);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(TradeRequests.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
|
||||
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(0x01F6u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x50001234u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x01F7u)] // CloseTradeNegotiations
|
||||
[InlineData(0x01FBu)] // DeclineTrade
|
||||
[InlineData(0x0204u)] // ResetTrade
|
||||
public void EmptyBodiedActions_WriteEnvelopeSequenceOpcodeOnly(uint opcode)
|
||||
{
|
||||
byte[] body = opcode switch
|
||||
{
|
||||
0x01F7u => TradeRequests.BuildCloseTradeNegotiations(3),
|
||||
0x01FBu => TradeRequests.BuildDeclineTrade(3),
|
||||
_ => TradeRequests.BuildResetTrade(3),
|
||||
};
|
||||
|
||||
Assert.Equal(12, body.Length);
|
||||
Assert.Equal(TradeRequests.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
|
||||
Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddToTrade_WritesItemAndSlot()
|
||||
{
|
||||
byte[] body = TradeRequests.BuildAddToTrade(5, 0x60000001u, 2u);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(0x01F8u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x60000001u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAcceptTrade_WritesTradePackFixedFieldsAndTwoEmptyLists()
|
||||
{
|
||||
byte[] body = TradeRequests.BuildAcceptTrade(
|
||||
gameActionSequence: 11,
|
||||
partnerGuid: 0x50000B0Bu,
|
||||
tradeStamp: 0d,
|
||||
tradeStatus: 0u,
|
||||
initiatorGuid: 0x5000A0A0u,
|
||||
initiatorAccepts: true,
|
||||
partnerAccepts: false);
|
||||
|
||||
Assert.Equal(48, body.Length);
|
||||
Assert.Equal(0x01FAu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x50000B0Bu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(0d, BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
|
||||
Assert.Equal(0x5000A0A0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28)));
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32)));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(36)));
|
||||
// Two zero-count PackableLists (AD-94 — ACE discards the payload).
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(40)));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(44)));
|
||||
}
|
||||
|
||||
// ── Inbound parsers ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseRegisterTrade_ReadsInitiatorPartnerStamp()
|
||||
{
|
||||
byte[] payload = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000001u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x50000002u);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(8), 0uL);
|
||||
|
||||
var parsed = GameEvents.ParseRegisterTrade(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000001u, parsed!.Value.Initiator);
|
||||
Assert.Equal(0x50000002u, parsed.Value.Partner);
|
||||
Assert.Equal(0uL, parsed.Value.Stamp);
|
||||
Assert.Null(GameEvents.ParseRegisterTrade(payload.AsSpan(0, 12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAddToTrade_ReadsGuidSideSlot()
|
||||
{
|
||||
byte[] payload = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000009u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u);
|
||||
|
||||
var parsed = GameEvents.ParseAddToTrade(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x60000009u, parsed!.Value.ItemGuid);
|
||||
Assert.Equal(2u, parsed.Value.Side);
|
||||
Assert.Equal(0u, parsed.Value.SlotIndex);
|
||||
Assert.Null(GameEvents.ParseAddToTrade(payload.AsSpan(0, 8)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTradeFailure_ReadsGuidAndReason()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000042u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x426u);
|
||||
|
||||
var parsed = GameEvents.ParseTradeFailure(payload);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x60000042u, parsed!.Value.ItemGuid);
|
||||
Assert.Equal(0x426u, parsed.Value.Reason);
|
||||
Assert.Null(GameEvents.ParseTradeFailure(payload.AsSpan(0, 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleGuidParsers_ReadTheGuid()
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000C0Cu);
|
||||
|
||||
Assert.Equal(0x50000C0Cu, GameEvents.ParseAcceptTrade(payload));
|
||||
Assert.Equal(0x50000C0Cu, GameEvents.ParseDeclineTrade(payload));
|
||||
Assert.Equal(0x50000C0Cu, GameEvents.ParseResetTrade(payload));
|
||||
Assert.Equal(0x50000C0Cu, GameEvents.ParseCloseTrade(payload));
|
||||
}
|
||||
}
|
||||
|
|
@ -239,6 +239,7 @@ public sealed class GameRuntimeTests
|
|||
GameRuntimeTeardownStage.CommunicationDisposed,
|
||||
GameRuntimeTeardownStage.FellowshipDisposed,
|
||||
GameRuntimeTeardownStage.AllegianceDisposed,
|
||||
GameRuntimeTeardownStage.TradeDisposed,
|
||||
GameRuntimeTeardownStage.IdentityDisposed,
|
||||
GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
var fellowship = new RuntimeFellowshipState();
|
||||
var allegiance = new RuntimeAllegianceState();
|
||||
var trade = new RuntimeTradeState();
|
||||
|
||||
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||
inventory.Transactions.IncrementBusyCount();
|
||||
|
|
@ -38,7 +39,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance);
|
||||
allegiance,
|
||||
trade);
|
||||
Assert.False(populated.IsConverged);
|
||||
|
||||
movement.Dispose();
|
||||
|
|
@ -48,6 +50,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
inventory.Dispose();
|
||||
fellowship.Dispose();
|
||||
allegiance.Dispose();
|
||||
trade.Dispose();
|
||||
entities.Dispose();
|
||||
|
||||
RuntimeSimulationOwnershipSnapshot retired =
|
||||
|
|
@ -59,7 +62,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance);
|
||||
allegiance,
|
||||
trade);
|
||||
Assert.True(retired.IsConverged);
|
||||
Assert.True(retired.EntityObjects.IsDisposed);
|
||||
Assert.True(retired.Physics.IsDisposed);
|
||||
|
|
@ -76,6 +80,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
var fellowship = new RuntimeFellowshipState();
|
||||
var allegiance = new RuntimeAllegianceState();
|
||||
var trade = new RuntimeTradeState();
|
||||
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||
inventory.Shortcuts.Changed += static () => { };
|
||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||
|
|
@ -139,7 +144,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance);
|
||||
allegiance,
|
||||
trade);
|
||||
|
||||
Assert.False(populated.IsConverged);
|
||||
Assert.Equal(1, populated.Inventory.ShortcutCount);
|
||||
|
|
@ -161,6 +167,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
inventory.Dispose();
|
||||
fellowship.Dispose();
|
||||
allegiance.Dispose();
|
||||
trade.Dispose();
|
||||
subscription.Dispose();
|
||||
|
||||
RuntimeGameplayOwnershipSnapshot retired =
|
||||
|
|
@ -171,7 +178,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance);
|
||||
allegiance,
|
||||
trade);
|
||||
|
||||
Assert.True(retired.IsConverged);
|
||||
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
|
||||
|
|
@ -191,6 +199,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
var fellowship = new RuntimeFellowshipState();
|
||||
var allegiance = new RuntimeAllegianceState();
|
||||
var trade = new RuntimeTradeState();
|
||||
|
||||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||
|
|
@ -215,6 +224,7 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
communication.Dispose();
|
||||
fellowship.Dispose();
|
||||
allegiance.Dispose();
|
||||
trade.Dispose();
|
||||
|
||||
RuntimeGameplayOwnershipSnapshot retired =
|
||||
RuntimeGameplayOwnership.Capture(
|
||||
|
|
@ -224,7 +234,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
|||
actions,
|
||||
movement,
|
||||
fellowship,
|
||||
allegiance);
|
||||
allegiance,
|
||||
trade);
|
||||
|
||||
Assert.True(retired.IsConverged);
|
||||
Assert.Equal(1, retired.Communication.DispatchFailureCount);
|
||||
|
|
|
|||
142
tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs
Normal file
142
tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Secure-trade owner conformance (2026-08-14): the 0x01FD–0x0208 event
|
||||
/// family against the lane B wire truths — including ACE's
|
||||
/// wrong-initiator RegisterTrade, its both-sides ResetTrade, and retail's
|
||||
/// remove-before-notice TradeFailure handling.
|
||||
/// </summary>
|
||||
public sealed class RuntimeTradeStateTests
|
||||
{
|
||||
private const uint Self = 0x50000001u;
|
||||
private const uint Partner = 0x50000002u;
|
||||
private const uint ItemA = 0x60000001u;
|
||||
private const uint ItemB = 0x60000002u;
|
||||
|
||||
[Fact]
|
||||
public void RegisterOpensAndDerivesPartnerDespiteAceGuidLandmine()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
|
||||
// ACE sends initiator == partner == the non-self guid on BOTH sides.
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
|
||||
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
|
||||
Assert.True(snapshot.IsOpen);
|
||||
Assert.Equal(Partner, snapshot.PartnerGuid);
|
||||
Assert.False(snapshot.SelfAccepted);
|
||||
Assert.False(snapshot.PartnerAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStagesPerSideAndDropsBothAcceptances()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
trade.ApplyAccept(Self, Self);
|
||||
trade.ApplyAccept(Partner, Self);
|
||||
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
|
||||
// Duplicate echo is a no-op stage-wise.
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
|
||||
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
|
||||
Assert.Equal(1, snapshot.SelfItemCount);
|
||||
Assert.Equal(1, snapshot.PartnerItemCount);
|
||||
Assert.Equal([ItemA], trade.View.GetItems(RuntimeTradeSide.Self));
|
||||
Assert.Equal([ItemB], trade.View.GetItems(RuntimeTradeSide.Partner));
|
||||
// Staging invalidates prior acceptance (the ClearTradeAcceptance
|
||||
// mirror).
|
||||
Assert.False(snapshot.SelfAccepted);
|
||||
Assert.False(snapshot.PartnerAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptDeclineTrackSelfVersusPartnerByGuid()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
|
||||
trade.ApplyAccept(Partner, Self);
|
||||
Assert.True(trade.View.Snapshot.PartnerAccepted);
|
||||
Assert.False(trade.View.Snapshot.SelfAccepted);
|
||||
|
||||
trade.ApplyAccept(Self, Self);
|
||||
Assert.True(trade.View.Snapshot.SelfAccepted);
|
||||
|
||||
trade.ApplyDecline(Partner, Self);
|
||||
Assert.False(trade.View.Snapshot.PartnerAccepted);
|
||||
Assert.True(trade.View.Snapshot.SelfAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsBothSidesButKeepsTheWindowOpen()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u));
|
||||
trade.ApplyAccept(Self, Self);
|
||||
|
||||
trade.ApplyReset();
|
||||
|
||||
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
|
||||
Assert.True(snapshot.IsOpen);
|
||||
Assert.Equal(0, snapshot.SelfItemCount);
|
||||
Assert.Equal(0, snapshot.PartnerItemCount);
|
||||
Assert.False(snapshot.SelfAccepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailureRemovesTheItemAndRecordsTheReason()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
|
||||
trade.ApplyFailure(new GameEvents.TradeFailure(ItemA, 0x426u));
|
||||
|
||||
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
|
||||
Assert.Equal(0, snapshot.SelfItemCount);
|
||||
Assert.Equal(ItemA, snapshot.LastFailureItemGuid);
|
||||
Assert.Equal(0x426u, snapshot.LastFailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAndGenerationClearConvergeTheLedger()
|
||||
{
|
||||
var trade = new RuntimeTradeState();
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
|
||||
trade.ApplyClose();
|
||||
Assert.False(trade.View.Snapshot.IsOpen);
|
||||
Assert.Equal(0, trade.View.Snapshot.SelfItemCount);
|
||||
|
||||
trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self);
|
||||
trade.Clear();
|
||||
Assert.False(trade.View.Snapshot.IsOpen);
|
||||
|
||||
trade.Dispose();
|
||||
Assert.True(trade.CaptureOwnership().IsConverged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventsBeforeRegisterAreIgnored()
|
||||
{
|
||||
using var trade = new RuntimeTradeState();
|
||||
|
||||
trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u));
|
||||
trade.ApplyAccept(Partner, Self);
|
||||
trade.ApplyReset();
|
||||
|
||||
RuntimeTradeSnapshot snapshot = trade.View.Snapshot;
|
||||
Assert.False(snapshot.IsOpen);
|
||||
Assert.Equal(0, snapshot.SelfItemCount);
|
||||
Assert.False(snapshot.PartnerAccepted);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue