acdream/src/AcDream.Core.Net/Messages/TradeRequests.cs
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

110 lines
5 KiB
C#

using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Secure-trade GameAction builders — retail's <c>CM_Trade</c> senders,
/// byte-checked against ACE's readers (the server acdream runs against).
/// </summary>
/// <remarks>
/// Wire research: <c>docs/research/2026-08-14-trade-laneB-wire.md</c> —
/// ACE, the retail decomp, and holtburger's independent Rust implementation
/// agree on every field ACE implements. Retail senders:
/// <c>Event_OpenTradeNegotiations @ 0x0056D300</c>,
/// <c>Event_CloseTradeNegotiations @ 0x0056D1E0</c>,
/// <c>Event_AddToTrade @ 0x0056D0D0</c>, <c>Event_AcceptTrade</c> (packing
/// <c>Trade::Pack @ 0x005B9FF0</c>), <c>Event_DeclineTrade @ 0x0056D270</c>,
/// <c>Event_ResetTrade @ 0x0056D3D0</c>. There is NO RemoveFromTrade
/// C→S action — retail's per-item removal is client-local; ACE's only
/// clear is the full-window ResetTrade (which clears BOTH sides).
/// </remarks>
public static class TradeRequests
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint OpenTradeNegotiationsOpcode = 0x01F6u;
public const uint CloseTradeNegotiationsOpcode = 0x01F7u;
public const uint AddToTradeOpcode = 0x01F8u;
public const uint AcceptTradeOpcode = 0x01FAu;
public const uint DeclineTradeOpcode = 0x01FBu;
public const uint ResetTradeOpcode = 0x0204u;
/// <summary>Open trade with <paramref name="partnerGuid"/> — ACE walks
/// the initiator into range (<c>CreateMoveToChain</c>) before both sides
/// receive RegisterTrade.</summary>
public static byte[] BuildOpenTradeNegotiations(
uint gameActionSequence, uint partnerGuid)
{
byte[] body = new byte[16];
WriteHeader(body, gameActionSequence, OpenTradeNegotiationsOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
return body;
}
public static byte[] BuildCloseTradeNegotiations(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, CloseTradeNegotiationsOpcode);
/// <summary>Stage an item. <paramref name="tradeSlot"/> is ACE-ignored
/// (it always echoes slot 0); retail sends its grid slot.</summary>
public static byte[] BuildAddToTrade(
uint gameActionSequence, uint itemGuid, uint tradeSlot = 0u)
{
byte[] body = new byte[20];
WriteHeader(body, gameActionSequence, AddToTradeOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), tradeSlot);
return body;
}
/// <summary>
/// Accept the current offer. The payload mirrors retail's
/// <c>Trade::Pack</c> fixed fields; ACE parses and then DISCARDS every
/// one of them (<c>HandleActionAcceptTrade()</c> takes zero arguments —
/// server state is fully self-derived), so the two trailing
/// PackableList&lt;ContentProfile&gt; item lists retail appends are sent
/// as zero-count lists here (register row AD-94).
/// </summary>
public static byte[] BuildAcceptTrade(
uint gameActionSequence,
uint partnerGuid,
double tradeStamp,
uint tradeStatus,
uint initiatorGuid,
bool initiatorAccepts,
bool partnerAccepts)
{
byte[] body = new byte[48];
WriteHeader(body, gameActionSequence, AcceptTradeOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), partnerGuid);
BinaryPrimitives.WriteDoubleLittleEndian(body.AsSpan(16), tradeStamp);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), tradeStatus);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(28), initiatorGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(32), initiatorAccepts ? 1u : 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(36), partnerAccepts ? 1u : 0u);
// Two zero-count item lists (see remarks / AD-94).
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(40), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(44), 0u);
return body;
}
public static byte[] BuildDeclineTrade(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, DeclineTradeOpcode);
/// <summary>Clear the trade window. ACE clears BOTH players' staged
/// items, not just the sender's (Player_Trade landmine — lane B §quirks).</summary>
public static byte[] BuildResetTrade(uint gameActionSequence)
=> BuildEmpty(gameActionSequence, ResetTradeOpcode);
private static byte[] BuildEmpty(uint gameActionSequence, uint opcode)
{
byte[] body = new byte[12];
WriteHeader(body, gameActionSequence, opcode);
return body;
}
private static void WriteHeader(byte[] body, uint sequence, uint opcode)
{
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), opcode);
}
}