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

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