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

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