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

440 lines
23 KiB
Markdown

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