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:
Erik 2026-08-14 11:49:13 +02:00
parent bee38b0746
commit 067cbea8a5
26 changed files with 2682 additions and 42 deletions

View file

@ -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,

View file

@ -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)));

View file

@ -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,

View file

@ -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,

View 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);
}
}
}

View file

@ -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(),

View file

@ -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";