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

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

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

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

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

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

23 KiB

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):

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 _sendGiveWorldSession.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.UseSelectedSelectionInteractionController.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):

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):

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:

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:

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):
    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:
    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, ...)DirectGameRuntimeCommandAdapterWorldSession.SendXxx).

5. WorldSession send pattern

All outbound sends in src/AcDream.Core.Net/WorldSession.cs follow:

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), ParseAddToTraderecord 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):

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 GameEventWiringLiveSessionEventRouterLiveSessionRuntimeFactory.cs:258-273, and commands through IGameRuntimeCommandsDirectGameRuntimeCommandAdapter → 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.