From 067cbea8a5b720767e1c813b9b3589f6c35d6fad Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 11:49:13 +0200 Subject: [PATCH] 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 --- .../retail-divergence-register.md | 4 +- docs/research/2026-08-14-trade-laneA-ui.md | 364 +++++++++++++++ docs/research/2026-08-14-trade-laneB-wire.md | 316 +++++++++++++ docs/research/2026-08-14-trade-laneC-seams.md | 440 ++++++++++++++++++ .../InteractionRetainedUiComposition.cs | 3 +- .../Net/LiveSessionCommandRouter.cs | 34 ++ .../Net/LiveSessionRuntimeFactory.cs | 15 +- .../UI/ItemInteractionController.cs | 25 + .../UI/Layout/SecureTradeUiController.cs | 293 ++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 108 ++++- src/AcDream.App/UI/WindowNames.cs | 1 + src/AcDream.Core.Net/GameEventWiring.cs | 88 +++- src/AcDream.Core.Net/Messages/GameEvents.cs | 79 +++- .../Messages/TradeRequests.cs | 110 +++++ src/AcDream.Core.Net/WorldSession.cs | 59 +++ src/AcDream.Runtime/GameRuntime.cs | 49 +- .../Gameplay/RuntimeGameplayOwnership.cs | 16 +- .../Gameplay/RuntimeTradeState.cs | 307 ++++++++++++ src/AcDream.Runtime/RuntimeGenerationReset.cs | 33 +- .../RuntimeSimulationOwnership.cs | 6 +- .../Session/LiveSessionEventRouter.cs | 35 +- .../UI/Layout/PowerbarLayoutProbeTests.cs | 32 ++ .../Messages/TradeRequestsTests.cs | 143 ++++++ .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 1 + .../Gameplay/RuntimeGameplayOwnershipTests.cs | 21 +- .../Gameplay/RuntimeTradeStateTests.cs | 142 ++++++ 26 files changed, 2682 insertions(+), 42 deletions(-) create mode 100644 docs/research/2026-08-14-trade-laneA-ui.md create mode 100644 docs/research/2026-08-14-trade-laneB-wire.md create mode 100644 docs/research/2026-08-14-trade-laneC-seams.md create mode 100644 src/AcDream.App/UI/Layout/SecureTradeUiController.cs create mode 100644 src/AcDream.Core.Net/Messages/TradeRequests.cs create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e157a736..0f4ab0d5 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 72 active rows (AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 74 active rows (AD-94 + AD-95 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists, and the trade panel's numeric-only count texts pending template verification; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -187,6 +187,8 @@ readiness/requeue adaptation. See | AD-90 | **Filed 2026-08-13 at the #389 mechanism-review fix round (finding M1).** Retail's smartbox divisor aspect is not raw width/height: `RenderDevice::ComputeAspectForViewport @0x0054f150` yields `(w/h) × m_DisplayAspectRatio × 0.75`, with `m_DisplayAspectRatio` fed by the registered `Render.AspectRatio` preference. At that preference's DEFAULT (4:3) the factor is exactly 1.0f and the expression collapses to raw w/h — which is what acdream uses. acdream carries no AspectRatio preference at all. Also folded in: retail's `SetFOVRad` gate arithmetic ACCEPTS NaN (x87 unordered-compare quirk) where acdream's port rejects it — unreachable in practice, deliberately not reproduced (mechanism review M3). | `src/AcDream.App/Rendering/RetailFieldOfView.cs` (class doc names this row) | Bit-exact at retail's registered default; the preference existed for 2003-era stretched-CRT correction with no modern counterpart. Reproducing it would add a user knob retail itself defaulted away. | A retail user who had changed `Render.AspectRatio` saw framing acdream cannot reproduce; anyone porting FOV behavior from a capture made with a non-default AspectRatio preference will measure a mismatch against our law. | `RenderDevice::ComputeAspectForViewport @0x0054f150`; `Render::SetFOVRad @0x0054b2d0`; consumer `D3DXMatrixPerspectiveFovLH @0x0059ab71`; docs/research/2026-08-13-389-fov-mechanism-review.md | | AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | +| AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | +| AD-95 | **Filed 2026-08-14 at the secure-trade feature.** The trade panel's two item-count texts (`0x10000080`/`0x10000087`) render the bare staged-item NUMBER; retail resolves `ID_SecureTrade_TotalItemsLabel` (`0x006F2D8D`) through the StringInfo template engine with the count substituted. The template's variable shape was not dumped/verified token-free, so the AD-85 numeric-fields disposition applies (data without invented surrounding words) until the same `ResolveTemplate` verification the confirmation dialogs got. | `src/AcDream.App/UI/Layout/SecureTradeUiController.cs` (`SetCount`) | Same argument as AD-85's remainder: an unverified template must not be guessed into a sentence. Verifying it is a one-probe task (the powerbar probe pattern). | The user sees "3" where retail shows the full "Total Items: 3"-style caption. The COUNT itself is correct. | `gmSecureTradeUI` string use (lane A, `docs/research/2026-08-14-trade-laneA-ui.md`); `ID_SecureTrade_TotalItemsLabel @0x006F2D8D` | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | --- diff --git a/docs/research/2026-08-14-trade-laneA-ui.md b/docs/research/2026-08-14-trade-laneA-ui.md new file mode 100644 index 00000000..1bbbcab3 --- /dev/null +++ b/docs/research/2026-08-14-trade-laneA-ui.md @@ -0,0 +1,364 @@ +# Retail secure-trade UI (`gmSecureTradeUI`) — decomp decode + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR +build, PDB-named). All addresses are `acclient.exe` v11.4186 file offsets +(module base ~`0x00400000`). The class is **`gmSecureTradeUI`**, not +`gmTradeUI` — grep the correct name if re-deriving. + +Element-type-id convention observed in this file (per `DynamicCast`/ +`GetUIElementType` pairs): `1`=Button, `3`(alias `0xa`? see ItemList note +below)=Field-ish base, `0xc`=Text, and high `0x100000XX` values are +per-class dynamic type ids assigned to more complex controls +(`gmSecureTradeUI` itself is `0x10000012`, `UIElement_ItemList` is +`0x10000031` (also answers to legacy id `5`), `UIElement_UIItem` (an +item icon inside a list/paperdoll) is `0x10000032`). + +## 1. Class shape: Create / Register / PostInit / ListenToElementMessage + +| Method | Address | Notes | +|---|---|---| +| `gmSecureTradeUI::gmSecureTradeUI` (ctor) | `0x004c97c0` | Base-inits as `UIElement_Field`, then wires in `ObjectRangeHandler` and `ItemListDragHandler` interface vtables (multiple-inheritance vtable-slot assignment — the pseudo-C shows two of these assignments mislabeled as `&gmUrgentAssistanceUI::\`vftable'` / plain vtable-thunk addresses; **this is a known Binary-Ninja mislabeling artifact, not a real base-class relationship** — trust the interface list (`ObjectRangeHandler`, `ItemListDragHandler`), not the printed symbol name). Zeroes the button/list pointer block (`memset(&m_pTradeButton, 0, 0x28)`), inits `splitItemStackSize=0`, `splitItemClassID=INVALID_DID`. | +| `gmSecureTradeUI::Create` | `0x004c9890` | `operator new(0x634)` (0x634-byte instance) → ctor. | +| `gmSecureTradeUI::Register` | `0x004c9cc0` | `UIElement::RegisterElementClass(0x10000012, gmSecureTradeUI::Create)`. **Element class id = `0x10000012`.** | +| `gmSecureTradeUI::DynamicCast` | `0x004c96d0` | Answers `0x10000012` (self) or `3`. | +| `gmSecureTradeUI::GetUIElementType` | `0x004c96f0` | Returns `0x10000012`. | +| `gmSecureTradeUI::PostInit` | `0x004ca160` | See binding table below. | +| `gmSecureTradeUI::ListenToElementMessage` | `0x004cae80` | See click table below. | +| `gmSecureTradeUI::~gmSecureTradeUI` / scalar-deleting-dtor | `0x004c9650` / `0x004c9870` | Nothing trade-specific. | + +### PostInit element bindings (`GetChildRecursive` + `DynamicCast`) + +All at `0x004ca160`. Order as they appear in code: + +| Child element id | `DynamicCast` arg | Field | Meaning | +|---|---|---|---| +| `0x10000086` | `1` (Button) | `m_pTradeButton` | Combined Accept/Decline action button (see click table). | +| `0x10000085` | `0xc` (Text) | `m_pSelfPlayerName` | Your own name label. | +| `0x10000087` | `0xc` (Text) | `m_pSelfTotalItemsLabel` | "N items" label, your side. | +| `0x10000088` | `0x10000031` (ItemList) | `m_pSelfItemsList` | **Your item grid.** Also calls `UIElement_ItemList::RegisterItemListDragHandler(eax_22, &this->vtable)` — `gmSecureTradeUI` is its own drag handler. | +| `0x1000007f` | *(no cast — raw `UIElement*`)* | `m_pOtherTradeStatusIndicator` | Partner "has accepted" status icon/indicator. | +| `0x1000007e` | `0xc` (Text) | `m_pOtherPlayerName` | Partner's name label. | +| `0x10000080` | `0xc` (Text) | `m_pOtherTotalItemsLabel` | "N items" label, partner side. | +| `0x10000081` | `0x10000031` (ItemList) | `m_pOtherItemsList` | **Partner's item grid** (no drag handler registered — you can't drag out of/into it). | +| `0x1000008a` | `1` (Button) | `m_pClearAllItemsButton` | "Clear all" / Reset button — same id is also read directly in `ListenToElementMessage` (see below), so it's bound to a field *and* hard-checked by id on click. | + +PostInit ends by tail-calling `gmSecureTradeUI::Reset(this)` unconditionally +(both the found- and not-found-`0x1000008a` paths converge there). + +Two more ids are referenced by id only (not bound to named fields in +PostInit): + +| id | Where used | Meaning | +|---|---|---| +| `0x1000008b` | `ListenToElementMessage` | Click → `this->vtable->SetVisible(0)`. No server notify — this is the panel's own close/"X" button; it just hides the window. | + +### `RegisterNoticeHandler` calls in `PostInit` + +`PostInit` also does 13 `GlobalEventHandler::GetGlobalEventHandler()-> +RegisterNoticeHandler(, &this->vtable)` calls (lines ~`0x004ca17a`– +`0x004ca2e5`). **The printed `` values are decompiler noise** — most +show as raw hex (`0x4dd231`…`0x4dd23a`, `0x186a8`, `0x186a9`, `0x186ab`) +and one shows as a symbol (`gmKeyboardUI::ListenToElementMessage`) despite +being an unrelated class's method — confirmed by checking `0x4dd230` +directly: it *is* the start of `gmKeyboardUI::ListenToElementMessage` +(`0x004dd230`), i.e. Binary Ninja is printing "nearest known symbol" for a +raw 32-bit notice-type constant that happens to numerically fall inside +`.text`. **Do not port these literal values** — they are opaque +compile-time notice-type tags, not meaningful addresses. What's reliable +is the *count* (13, matching the 13 `RecvNotice_*`/`Recv...` handlers +below) and each handler's own address/behavior. + +## 2. `RecvNotice_*` / notice handlers on `gmSecureTradeUI` + +| Handler | Address | Behavior | +|---|---|---| +| `RecvNotice_RegisterTrade(iidInitiator?, iidPartner, arg4)` | `0x004ca5c0` | Calls `SetTradePartner(this, )`, then `CPlayerSystem::RegisterObjectRangeHandler(playerSystem, &this->m_hashElementsRegisteredWith, arg3/*partner id*/, 0.0, 1, 0, 0.0, 0.0)` — **registers an object-range handler on the trade partner**, so leaving visual range auto-closes the trade (see `OnObjectRangeExit` below). This is the "trade window opened" handler. | +| `RecvNotice_AddItemToTrade(itemId, side, slot)` | `0x004ca500` | `side==2` → `AddPartnerItem(itemId, slot)`; `side==1` → `AddMyItem(itemId, slot)`. | +| `RecvNotice_RemoveItemFromTrade(itemId, side)` | `0x004ca630` | `side==2` → `RemovePartnerItem`; `side==1` → `RemoveAddedItem`. | +| `RecvNotice_AcceptTrade(playerGuid)` | `0x004c9ce0` | `playerGuid==0`: reset-ish path — touches hash-registration bucket `[0]` (self status) then `UpdateTradeButtonState`. `playerGuid==local player`: bucket `[0]` (self) gets state `6`, then `UpdateTradeButtonState`. Otherwise (partner accepted): bucket `[4]` (== `m_pOtherTradeStatusIndicator`'s registration slot) gets state `6`. *(The "`ecx->m_hashKey->m_alphaImage(N)`" call syntax is another BN vtable-slot-mislabel — functionally this is "set the accept-status indicator element's state to N" via the notice-registration hash, most likely a plain `SetState` dispatch, not a literal alpha-image setter; treat the numeric state, not the printed method name, as ground truth.)* | +| `RecvNotice_DeclineTrade(playerGuid)` | `0x004c9d70` | Mirror of Accept: `playerGuid==local player` → bucket `[0]` state `1` + `UpdateTradeButtonState`; otherwise (partner declined) → bucket `[4]` (partner indicator) state `0xd`. | +| `RecvNotice_ClearTradeAcceptance()` | `0x004ca540` | Tailcalls `Reset()`. | +| `RecvNotice_CloseTrade(arg2)` | `0x004ca550` | `Reset()`, then if hash bucket `[5]` is bound, sets that element's text to the empty string (`PStringBase::s_NullBuffer`) — likely clears a status/announcement text field. | +| `RecvNotice_ResetTrade(arg2)` | `0x004ca670` | Tailcalls `Reset()`. | +| `RecvNotice_TradeFailure(itemId, arg3)` | `0x004ca680` | `RemoveAddedItem(itemId)` — rolls back an optimistically-added self item that the server rejected. | +| `RecvNotice_TradeAnItemForDummies(itemId)` | `0x004caf30` | Tailcalls `TradeAnItemForDummies(itemId)` (see below — this is the "double-click to add" convenience path, driven by a server/engine notice, not just UI). | +| `RecvNotice_ServerSaysAttemptFailed(arg2)` | `0x004c98c0` | `this->m_hashElementsRegisteredWith...m_aInplaceBuckets[9] = nullptr` — clears a registration slot directly (no method call); likely cancels an in-flight optimistic-add tracking entry. Low confidence on exact semantics — flagged, not guessed. | +| `RecvNotice_ServerSaysMoveItem(...)` → `ServerSaysMoveItem` | `0x004cad30` → `0x004cac20` | Full signature `(itemId, arg3, arg4, arg5, destContainerOrPlayerId, arg7, arg8, arg9)`. If `destContainerOrPlayerId == m_iidTradePartner` and `ClientTradeSystem::IsPartnerTradingItem(itemId)` and the item isn't already in `m_pOtherItemsList`: pulls the item off any pending destruction queue and calls `AddPartnerItem(itemId, ClientTradeSystem::GetItemLocationInPartnerTradeList(itemId))`. This is the "partner's item physically arrived via a container-move notice" path (items placed into the trade appear to move into a hidden container owned by the partner; this handler is what makes that show up in the partner grid). | +| `RecvNotice_ItemAttributesChanged(itemId, arg3)` → `ItemAttributesChanged` | `0x004cad40` → `0x004ca9d0` | Only acts if `this->splitItemID != 0` (i.e. mid-split-for-trade) and `arg3 & 1`. If the changed item matches the pending split's class id and its new stack size equals the expected split remainder size, calls `AddItem(itemId, 0, 0, 0, 1)` and clears `splitItemID`. This is the completion of the "split stack before trading" flow started in `AcceptDragObject`. | +| `OnObjectRangeExit(arg2)` | `0x004ca4c0` | If `arg2 == ClientTradeSystem::GetTradeSystem()->m_iidTradePartner`: `ClientTradeSystem::CloseTradeNegotiations()` + `Reset()`. This is the range-based auto-close wired up by `RecvNotice_RegisterTrade`'s `RegisterObjectRangeHandler` call. | +| `OnVisibilityChanged(arg2)` | `0x004ca470` | On becoming hidden (need to confirm polarity from `arg2`, not fully traced) calls `Reset()`. | + +`Reset()` itself (`0x004ca100`): guarded by an internal bit-flag check +(`(this->__inner23 >> 0x11) & 1`, likely "is this element actually +constructed/active" — early-outs otherwise). Then: `SetTradePartner(0)` +(clears partner name), `m_pOtherTradeStatusIndicator->SetState(0xd)` +(neutral), `FlushTradeLists()`, `SetMyItemNumber()`, +`SetOtherItemNumber()`, `UpdateTradeButtonState()`. + +`FlushTradeLists()` (`0x004c9ac0`): for every item currently in +`m_pSelfItemsList`, calls `ACCWeenieObject::SetTradeState(item, 0)` +(un-flags it as "in a trade") then `ItemList_Flush`. For every item in +`m_pOtherItemsList`, if it's not player-owned and not already flagged +container-location `0x3f00000`, queues it for destruction +(`AddContentsToDestructionQueue`) — the client-side proxy objects +representing the partner's offered items are throwaway and get destroyed +when the trade lists are cleared. + +## 3. Opening the trade panel + +### Use-on-player path (confirmed) + +`CPlayerSystem::UsingItem(itemId, arg3, arg4)` at `0x00562f70` calls +`ItemHolder::DetermineUseResult(item)` (`0x00588460`) and switches on +`(result - 2)`. **`case 3` → result `5` → `ClientTradeSystem:: +AttemptToOpenTradeNegotiations(GetTradeSystem(), itemId)`** at +`0x00563022`. + +`ItemHolder::DetermineUseResult` returns `5` specifically at +`0x005885f7` when: the target is *not* player-owned, has no capacity/ +component-pack shortcut, isn't a "negative InqType" special object, +isn't directly `ItemUses::IsUseable`, **and `esi->vtable->IsPlayer()` +is true and `esi->id != `** — i.e., **"use" on any +other player, with no other higher-priority use-result, resolves to +"open trade."** This is the canonical "use on player → trade" trigger. +`ItemHolder::DetermineUseResult` — `0x00588460`. + +`ClientTradeSystem::AttemptToOpenTradeNegotiations` — `0x0056dee0`: +refuses (shows "You need to be in peace mode to …") if +`ClientCombatSystem::GetCombatSystem()->combatMode != NONCOMBAT_COMBAT_MODE`. +Otherwise sends `CM_Trade::Event_OpenTradeNegotiations(targetPlayerId)` +(`0x0056df6a`) — the outbound wire request. + +### Drag-item-on-player path (confirmed) + +`ItemHolder::AttemptPlaceIn3D(arg1, arg2, arg3)` at `0x00588600` is the +generic "item dropped onto object X in the 3D view" dispatcher. At +`0x005887d0`: + +``` +if (PlayerModule::DragItemOnPlayerOpensSecureTrade(&playerModule) != 0 + && droppedOnObject->vtable->IsPlayer() != 0) +{ + ClientTradeSystem::AttemptToTradeItem(GetTradeSystem(), targetPlayerId, droppedItemId); + return 0; +} +``` + +So the player-option gate is real and it's checked **before** the +`InqType()==0x10` (give-directly) and container/lock checks that follow +in the same function — dragging onto a player short-circuits straight to +trade-attempt when the option is on, before any "give" logic runs. + +- Option getter/setter: `PlayerModule::DragItemOnPlayerOpensSecureTrade` + (`0x005d31b0`) / `PlayerModule::SetDragItemOnPlayerOpensSecureTrade` + (`0x005d31c0`). Player-option enum id: `DragItemOnPlayerOpensSecureTrade_PlayerOption`. + StringTable label/help keys: `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade` + / `..._Help`, registered via `compute_str_hash` at `0x004a0f5f` / + `0x004a0f85`, added to the options page via + `PlayerOptionPage::AddToggleOption` at `0x004a0fa4`. + +`ClientTradeSystem::AttemptToTradeItem(targetPlayerId, itemId)` — +`0x0056df80`: requires the item be player-owned. If already trading with +someone: + - same partner → `CM_Trade::SendNotice_TradeAnItemForDummies(itemId)` + (adds the item directly to the already-open trade — the "convenience" + add-while-already-negotiating path). + - different partner → refuses with "You are already trading with + som…". +If not yet trading: calls `ItemHolder::UseObject(targetPlayerId, 0, 0)` +(i.e. re-enters the same **use** path as above, effectively "use the +target player") and stashes `attemptTradeToPlayerID` / +`attemptTradeObjectID` for later. Once +`ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` (`0x0056e050`, the +inbound "trade window opened" handler that also fires +`CM_Trade::SendNotice_RegisterTrade` → `gmSecureTradeUI::RecvNotice_RegisterTrade` +above) sees `m_iidTradePartner == attemptTradeToPlayerID`, it calls +`AttemptToTradeItem` again to actually queue the drag-dropped item into +the now-open trade. + +### Internal UI-queue notice ids (bonus — confirms §1's `PostInit` id noise is noise) + +`CM_Trade::DispatchUI_Recv_*` (the layer between the network/engine queue +and the `RecvNotice_*` UI calls) gate on small, contiguous internal +tag values — these are **not** the raw wire opcodes, but they cross-check +cleanly against each other and confirm the `PostInit` `RegisterNoticeHandler` +hex noise (§1) is unrelated decompiler artifact, not meaningful data: + +| Notice | Internal tag | Dispatch fn addr | +|---|---|---| +| RegisterTrade | `0x1fd` | `0x006acf20` | +| OpenTrade | `0x1fe` | `0x006acef0` | +| CloseTrade | `0x1ff` | `0x006ace90` | +| AddToTrade | `0x200` | `0x006ace20` | +| RemoveFromTrade | `0x201` | `0x006acf80` | +| AcceptTrade | `0x202` | `0x006ace09`/`0x006acdf0` | +| DeclineTrade | `0x203` | `0x006ace60`(dispatch fn header `0x006acec0`) | +| ResetTrade | `0x205` | `0x006acfb0` | +| TradeFailure | `0x207` | `0x006acfe0` | +| ClearTradeAcceptance | `0x208` | `0x006ace60` | + +## 4. Item grids + +- **Your grid**: element id `0x10000088`, bound to `m_pSelfItemsList`, + type `UIElement_ItemList` (element-type-id `0x10000031`). This list + registers itself as an `ItemListDragHandler` target with `this` + (`gmSecureTradeUI`) as handler — items are dragged **in** here by the + player. Individual entries, when read back with + `UIElement_ListBox::GetItem`, are `DynamicCast(0x10000032)` — a + `UIElement_UIItem` icon wrapping an `ACCWeenieObject` (`weenObj` field). +- **Partner's grid**: element id `0x10000081`, bound to + `m_pOtherItemsList`, same `UIElement_ItemList` type, **no drag handler + registered** — populated only by network notices + (`AddPartnerItem`/`RemovePartnerItem`/`ServerSaysMoveItem`), not by + local drag-drop. +- **Counts**: `SetMyItemNumber()` (`0x004c98d0`) / `SetOtherItemNumber()` + (`0x004c9970`) build a `StringInfo` with + `SetStringIDandTableEnum(&info, , 0x10000001)` + + `AddVariable_Int(count)` and write the resolved string into + `m_pSelfTotalItemsLabel` / `m_pOtherTotalItemsLabel`. The printed `` + literal is `0`, which is almost certainly the decompiler showing the + static pre-initializer value of the global `ID_SecureTrade_TotalItemsLabel` + (see §5) rather than the true runtime-computed hash — same class of + artifact as the `PostInit` notice ids. Table-enum `0x10000001` is a + StringTable category constant, not further resolved here. +- **Accept/decline is presented via**: + 1. `m_pTradeButton` (id `0x10000086`) — one physical button whose + current `m_state` decides what a click does (see §1 click table): + `m_state==6` → click triggers `AcceptTheTrade`; `m_state==1` → + click triggers `DeclineTheTrade`. `UpdateTradeButtonState()` + (`0x004c9700`) disables the button (state `0xd`) whenever total + items across both sides is `0`, and re-enables (state `1`) once + items exist. **Caution**: `UIElement_Button::SetState` (`0x00471da0`) + shows this button uses a "latch" attribute pair (attrs `0xb`/`0xe`) + where `SetState(6)`/`SetState(1)` toggle a latch flag and + early-return *without* necessarily rewriting `m_state` to that + literal value unless the latch was already in the requested + position, in which case it falls through to the generic + disabled-state path which does write `m_state`. The exact + accept/decline toggle semantics are therefore genuinely convoluted + in the retail binary; recommend the port drive the button's visual + state from `ClientTradeSystem`'s own accepted/declined booleans + rather than replicating this latch dance literally, and validate + against a live retail trade if uncertain (this is exactly the kind + of "state interacts with prior state in ways not obvious from + reading" case the CLAUDE.md's cdb workflow exists for). + 2. `m_pOtherTradeStatusIndicator` (id `0x1000007f`) — the partner-side + accept/decline indicator icon, driven directly by + `RecvNotice_AcceptTrade`/`RecvNotice_DeclineTrade` (state `6` = + accepted, `0xd` = neutral/declined) and reset to `0xd` on every + `AddMyItem`/`AddPartnerItem`/`RemoveAddedItem`/`RemovePartnerItem` + (any list change silently un-accepts the visual, matching retail's + "adding an item clears both sides' acceptance" rule). + 3. Drag-affordance color feedback during hover uses the same generic + accept/reject drag-state pair used elsewhere in the UI: + `UIElement_UIItem::SetDragAcceptState(item, 0x10000040)` (acceptable + — green) / `0x10000041` (rejected — red), set from + `OnItemListDragOver` (`0x004ca980`) via `DragItemAcceptable`. + +`DragItemAcceptable(itemId, quiet)` (`0x004ca6a0`): if the item is +player-owned and not already in the self list → acceptable. If not +player-owned: acceptable only path is absent (falls to `return 0`); if +`quiet==0` it also posts a rejection `StringInfo` via +`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` (the literal string text +itself is unresolved — printed as a mislabeled vtable-slot symbol, +another string-adjacent-to-vtable BN artifact, not a real symbol +reference). + +`AddItem(itemId, slot, quietFlag, splitAllowedFlag, fromRecursion)` +(`0x004ca780`): if the item has no contained items/containers (i.e. not +itself a container), inserts it directly into `m_pSelfItemsList` and +calls `ClientTradeSystem::AddItemToSelfTradeList` (the outbound wire +call). If it *is* a container (has contained items) and `splitAllowedFlag` +is set, it instead announces "Trading contents of %s" and recursively +calls `AddItem` for every contained item — **dragging a container into +the trade grid trades its contents individually, not the container +itself.** + +`AcceptDragObject(itemId)` (`0x004caa40`): if `DragItemAcceptable` +passes and the dropped item's current stack size already equals the max +split size, adds it directly. Otherwise attempts +`ItemHolder::AttemptToPlaceInContainer` to split off the correct amount +first (stashing `splitItemID`/`splitItemClassID`/`splitItemStackSize`, +announcing "Splitting the %s before trading …"); the actual add happens +later when `RecvNotice_ItemAttributesChanged`/`ItemAttributesChanged` +sees the split completion (§2). If the split attempt itself fails, +announces "Cannot split the stack to trade …". + +`TradeAnItemForDummies(itemId)` (`0x004cad50`): the convenience +"just trade this stack, splitting-and-all" entry point invoked from the +`RecvNotice_TradeAnItemForDummies` notice (fired e.g. from +`AttemptToTradeItem`'s same-partner re-add case). Refuses with "You must +split the stack before …" if the item is the globally +`ACCWeenieObject::selectedID` and a split is already in flight +(`GenItemHolder::splitSize == maxSplitSize`); otherwise calls `AddItem` +directly. + +`HandleDropRelease` (`0x004cae10`) / message id `0x15` in +`ListenToElementMessage`: only processes a drop if the drop's ancestor +chain lands inside `m_pSelfItemsList` (`UIElement::IsAncestorOfMe`) — +confirms drops are only ever accepted onto your own grid, never the +partner's. + +## `ListenToElementMessage` click table (`0x004cae80`) + +| `idMessage` | `idElement` | Action | +|---|---|---| +| `1` (click) | `0x10000086` (trade button) | `m_state==6` → `AcceptTheTrade()`; `m_state==1` → `DeclineTheTrade()`. | +| `1` (click) | `0x1000008a` (clear-all) | `ClientTradeSystem::ResetTrade(GetTradeSystem())` — outbound reset. | +| `1` (click) | `0x1000008b` (close/X) | `this->vtable->SetVisible(0)` — local-only hide, no wire traffic. | +| `0x15` (drop) | — | `HandleDropRelease` (self-grid-only, see §4). | + +`AcceptTheTrade()` (`0x004c9a10`): before sending accept, verifies the +locally-displayed item counts (`GetNumUIItems` on both lists) match the +server-known counts (`ClientTradeSystem::GetNumSelfObjectsInTrade`/ +`GetNumPartnerObjectsInTrade`); mismatch → +`ClientTradeSystem::NotifyServerThatTradeIsOutOfSync()` instead of +accepting — an explicit desync guard the port should replicate. +`DeclineTheTrade()` (`0x004c9a90`) is unconditional: +`ClientTradeSystem::DeclineTrade()`. + +## 5. StringTable keys + +Only one trade-panel-specific key was found via `compute_str_hash("ID_...")` +scan of the whole file: + +| Key | Hash-init address | +|---|---| +| `ID_SecureTrade_TotalItemsLabel` | `0x006f2d8d` | + +Related but not-panel-body keys (player options / chat, not the panel +itself): `ID_PlayerOption_DragItemOnPlayerOpensSecureTrade` (+`_Help`) at +`0x004a0f5f`/`0x004a0f85`; `ID_PlayerOption_IgnoreTradeRequests` (+`_Help`) +at `0x004a0eee`/`0x004a0f14`; `ID_ChatOption_TextFilter_Trade` (+`_Desc`) +at `0x006f06cd`/`0x006f06ed`; `ID_Chat_ChatTargetMenuTrade` / +`ID_Chat_TellToTrade` at `0x006f39ad`/`0x006f3a6d`. + +**No other `ID_SecureTrade*` or `ID_Trade*` keys exist in the pseudo-C.** +Player-name and other-side labels are populated directly from +`ACCWeenieObject::GetObjectNameWide` (not StringTable), and the button's +own caption/tooltip text is presumably baked into the LayoutDesc/DAT +authoring for element `0x10000086` rather than resolved at runtime here — +the porting engineer should pull the actual authored panel (element class +`0x10000012`, its children `0x1000007e`–`0x1000008b`) from the game's +LayoutDesc DAT resource via the existing `LayoutImporter`/UI-Studio +tooling to get real control names/captions/positions; this decomp pass +only recovers behavior, not layout. + +## NOT FOUND + +- The literal wire/network opcode for the trade `GameAction`/`GameEvent` + family (as opposed to the internal `0x1fd`–`0x208` UI-queue tags in + §3) was not located in `acclient.h` or the pseudo-C by direct grep; + cross-check `references/ACE/` server-side trade handlers if the exact + byte-level wire opcode is needed for a lane-B/network task. +- The exact text of the two `StringInfo`/`ECM_UI::SendNotice_DisplayStringInfo` + messages in `DragItemAcceptable` and one in `AcceptDragObject` that show + as mislabeled vtable-slot symbols instead of string literals (BN + string-adjacent-to-vtable artifact) — content unrecoverable from this + file alone. +- Full struct layout / field offsets for `gmSecureTradeUI` and + `ClientTradeSystem` are not present in `acclient.h` (not reconstructed + in this PDB pass); field names above are taken from the pseudo-C's own + `this->fieldName` labels, which the decompiler DID resolve correctly + (these are real PDB member names, unlike the notice-id/string-literal + artifacts flagged above). diff --git a/docs/research/2026-08-14-trade-laneB-wire.md b/docs/research/2026-08-14-trade-laneB-wire.md new file mode 100644 index 00000000..38287ea0 --- /dev/null +++ b/docs/research/2026-08-14-trade-laneB-wire.md @@ -0,0 +1,316 @@ +# Secure-trade wire protocol (Lane B research) + +2026-08-14. Sources: ACE (`references/ACE/Source/ACE.Server/`, authoritative +for what our local server accepts/sends), retail decomp +(`docs/research/named-retail/acclient_2013_pseudo_c.txt`, `CM_Trade` / +`ClientTradeSystem` / `Trade`), holtburger Rust client protocol +(`references/holtburger/crates/holtburger-protocol/src/messages/trade/`). +All three agree byte-for-byte on every field ACE actually implements; no +disagreements found. holtburger implements the FULL retail set (including +`OpenTrade`/`RemoveFromTrade`, which ACE never sends) — its structs are +cited as the independent cross-check. + +Frame headers (`GameActionPacket.cs:9-17`, `GameEventMessage.cs:14-26`): + +- C→S action frame: opcode `0xF7B1` (GameAction) → `[sequence u32][GameActionType u32][action-specific fields]`. +- S→C event frame: opcode `0xF7B0` (GameEvent) → `[PlayerGuid u32][GameEventSequence u32][GameEventType u32][event-specific fields]`. + +All guids below are `u32` (`ObjectGuid`/`Guid`, little-endian). + +## Table 1 — client→server actions (GameActionType) + +| Opcode | Name | Payload fields (order, type) | ACE file:line | Retail sender (address) | +|---|---|---|---|---| +| `0x01F6` | OpenTradeNegotiations | `tradePartnerGuid: u32` | `GameActionOpenTradeNegotiations.cs:10` | `CM_Trade::Event_OpenTradeNegotiations` @ `0x0056d300` | +| `0x01F7` | CloseTradeNegotiations | (none) | `GameActionCloseTradeNegotiations.cs:8` | `CM_Trade::Event_CloseTradeNegotiations` @ `0x0056d1e0` | +| `0x01F8` | AddToTrade | `itemGuid: u32`, `tradeSlot: u32` | `GameActionAddToTrade.cs:9-10` | `CM_Trade::Event_AddToTrade` @ `0x0056d0d0` | +| `0x01F9` | *(unassigned — reserved for RemoveFromTrade)* | n/a | not in `GameActionType.cs` | retail has no `Event_RemoveFromTrade` C→S sender either — removal is client-local-only (see quirks) | +| `0x01FA` | AcceptTrade | `partnerGuid: u32`, `tradeStamp: f64`, `tradeStatus: u32`, `initiatorGuid: u32`, `initiatorAccepts: u32(bool)`, `partnerAccepts: u32(bool)`, *(then `self_list`/`partner_list`, variable-length `PackableList`, ACE never reads these — see quirks)* | `GameActionAcceptTrade.cs:11-16` | `CM_Trade::Event_AcceptTrade` @ `0x0056ad010`, packing `Trade::Pack` @ `0x005b9ff0` | +| `0x01FB` | DeclineTrade | (none) | `GameActionDeclineTrade.cs:8` | `CM_Trade::Event_DeclineTrade` @ `0x0056d270` | +| `0x0204` | ResetTrade | (none) | `GameActionResetTrade.cs:8` | `CM_Trade::Event_ResetTrade` @ `0x0056d3d0` | + +The 6 fixed `AcceptTrade` fields are exactly `Trade::Pack`'s first 6 members +(`_partner, _stamp, _status, _initiator, _accepted, _p_accepted` — +`acclient_2013_pseudo_c.txt:457619-457648`); holtburger's +`AcceptTradeActionData` (`holtburger-protocol/src/messages/trade/actions.rs:159-205`, +test fixture at `:265-280`) reproduces the identical 24-byte-after-guid +layout independently, confirming the read order. + +## Table 2 — server→client events (GameEventType) + +| Opcode | Name | Payload fields (order, type) | ACE file:line | Retail parser (address) | +|---|---|---|---|---| +| `0x01FD` | RegisterTrade | `initiator: u32(guid)`, `partner: u32(guid)`, `stamp: u64` (ACE always writes `0L`) | `GameEventRegisterTrade.cs:10-12` | `ClientTradeSystem::Handle_Trade__Recv_RegisterTrade(this, initiator, partner, double stamp)` @ `0x0056e050`, dispatched via `DispatchUI_Recv_RegisterTrade` @ `0x006acf20` (type check `== 0x1fd`) | +| `0x01FE` | OpenTrade | `partnerGuid: u32` | **not implemented by ACE — no C# class emits `GameEventType.OpenTrade`** | `ClientTradeSystem::Handle_Trade__Recv_OpenTrade` @ `0x0056d930`, dispatch @ `0x006acef0` (`== 0x1fe`) | +| `0x01FF` | CloseTrade | `endTradeReason: u32` (`EndTradeReason`: Normal=1, EnteredCombat=2, Canceled=0x51) | `GameEventCloseTrade.cs:10` | `Handle_Trade__Recv_CloseTrade` (calls `SendNotice_CloseTrade`), dispatch @ `0x006ace90` (`== 0x1ff`) | +| `0x0200` | AddToTrade | `objectGuid: u32`, `tradeSide: u32` (Self=1, Partner=2), `slot: u32` (ACE always writes `0`) | `GameEventAddToTrade.cs:10-12` | dispatch @ `0x006ace20` (`== 0x200`), reads 3 dwords at +4/+8/+0xc | +| `0x0201` | RemoveFromTrade | `objectGuid: u32`, `mode: u32` (1 = remove one, 2 = remove all matching qty — `Trade::RemoveItem`) | **not implemented by ACE — no C# class emits `GameEventType.RemoveFromTrade`** | `Handle_Trade__Recv_RemoveFromTrade` @ `0x0056dc00`, dispatch @ `0x006acf80` (`== 0x201`) | +| `0x0202` | AcceptTrade | `whoAccepted: u32(guid)` | `GameEventAcceptTrade.cs:10` | `Handle_Trade__Recv_AcceptTrade` @ `0x0056dc40`, dispatch @ `0x006acdf0` (`== 0x202`) — client compares `arg2` against its own `SmartBox::player_id` to know self-vs-partner | +| `0x0203` | DeclineTrade | `whoDeclined: u32(guid)` | `GameEventDeclineTrade.cs:10` | dispatch @ `0x006acec0` (`== 0x203`) | +| `0x0205` | ResetTrade | `whoReset: u32(guid)` | `GameEventResetTrade.cs:10` | `Handle_Trade__Recv_ResetTrade` (calls `Trade::Reset`), dispatch @ `0x006acfb0` (`== 0x205`) | +| `0x0207` | TradeFailure | `objectGuid: u32`, `reason: u32` (`WeenieError`) | `GameEventTradeFailure.cs:10-11` | `Handle_Trade__Recv_TradeFailure` @ `0x0056d990` (calls `Trade::RemoveItem(objectGuid, 1)` before UI notice), dispatch @ `0x006acfe0` (`== 0x207`) | +| `0x0208` | ClearTradeAcceptance | (none) | `GameEventClearTradeAcceptance.cs` (no extra fields) | dispatch @ `0x006ace60` (`== 0x208`) | + +holtburger's `events.rs` independently reproduces every field above, +including the "always 0 in ACE" comments on `RegisterTradeEventData.unknown` +(`:29`) and `AddToTradeEventData.slot` (`:83`) — these comments were written +from observing ACE traffic, corroborating the ACE source read. + +`SendNotice_*` retail functions (e.g. `SendNotice_AcceptTrade` @ `0x0056ad460`) +are **not** wire sends — they walk `gmGlobalEventHandler`'s registered UI +notice-handler list to update the local trade window after a `Recv_*` +dispatch. Do not confuse with `Event_*` (the only C→S wire builders, via +`Proto_UI::SendToWeenie`). + +## Sequencing narrative + +### Happy path: A initiates trade with B, both add items, both accept + +1. **A → S**: `OpenTradeNegotiations(0x01F6)` targeting B's guid + (`Player_Trade.cs:30-100`). +2. Server-side checks in order (`HandleActionOpenTradeNegotiations`, + `initiator=true` branch, `:32-83`): A not Olthoi → B online → B not + Olthoi → B not `IgnoreAllTradeRequests` → neither already `IsTrading` → + neither in combat mode → **A moves/rotates to B via `CreateMoveToChain`** + (this is the "if in range" distance gate — failure sends + `WeenieError.TradeMaxDistanceExceeded`, no trade starts). +3. On successful approach, **S → A**: `RegisterTrade(0x01FD)` with + `(initiator=B.Guid, partner=B.Guid, 0L)` — note ACE passes `tradePartner.Guid` + for BOTH fields here (`Player_Trade.cs:80`), not `(A.Guid, B.Guid)`; this + looks like an ACE bug relative to retail's `_partner`/`_initiator` + semantics, but it is what ships (flagged again in Quirks below). +4. Internally A calls `tradePartner.HandleActionOpenTradeNegotiations(A.Guid, initiator:false)` + (`:82`) — this is a same-process direct call into B's Player object, not + a wire message. B's non-initiator branch (`:85-99`) sets + `IsTrading=true` on both, clears both `ItemsInTradeWindow`, sets + `TradePartner` cross-links, then: +5. **S → B**: `RegisterTrade(0x01FD)` with `(initiator=B.Guid, partner=B.Guid, 0L)` + (`:98` — same "wrong" both-fields-partner value, since this runs as B's own + `Session`). +6. **A → S**: `AddToTrade(0x01F8)` with `(itemGuid, tradeSlot)` for each item A + drags into the window. Server (`HandleActionAddToTrade`, `:116-175`): + rejects if `TradeTransferInProgress`; clears both sides' + `TradeAccepted`; resolves the item from A's inventory or equipped slot; + refuses attuned/pet-bound items and uncarryable-uniques (see Quirks); + adds to `A.ItemsInTradeWindow`; **S → A**: `AddToTrade(0x0200)` + `(itemGuid, TradeSide.Self=1, 0)` immediately; then after a + 0.001s `ActionChain` delay, **S → B**: `AddToTrade(0x0200)` + `(itemGuid, TradeSide.Partner=2, 0)`. Same flow mirrored when B adds + items (roles of Self/Partner flip per session). +7. **A → S**: `AcceptTrade(0x01FA)` (full `Trade::Pack` payload, but ACE only + parses the header — no fields are used). Server + (`HandleActionAcceptTrade`, `:196-216`): sets `A.TradeAccepted=true`; + **S → A**: `AcceptTrade(0x0202)` `(whoAccepted=A.Guid)` + + `CommunicationTransientString("You have accepted the offer")`; **S → B**: + `AcceptTrade(0x0202)` `(whoAccepted=A.Guid)` + + `CommunicationTransientString("{A.Name} has accepted the offer")`. If + `B.TradeAccepted` is already true, `FinalizeTrade(B)` runs now; otherwise + nothing further happens until B also sends AcceptTrade (repeats this step + for B, and then it's B's `HandleActionAcceptTrade` that finds + `target.TradeAccepted==true` and calls `FinalizeTrade`). +8. **`FinalizeTrade`** (`:218-283`), runs on whichever side's accept was + second: + - `VerifyTrade_BusyState` — if either player `IsBusy`, abort: both get a + `CommunicationTransientString` explaining who's busy, and + `ClearTradeAcceptance` fires on both (**S → both**: + `ClearTradeAcceptance(0x0208)`, no fields — see step "Failure: busy / + inventory" below). + - `VerifyTrade_Inventory` — re-resolves both `ItemsInTradeWindow` sets by + guid (if any item vanished, `HandleActionDeclineTrade` fires for that + side instead); then checks `CanAddToInventory` (burden + free-slot + capacity) for the INCOMING items on both sides. Failure → per-side + `CommunicationTransientString` (encumbered vs. no-free-slots wording) + + `ClearTradeAcceptance` on both, trade stays open with items still in + the window. + - On success: `IsBusy=true` on both, `TradeTransferInProgress=true` on + both; **S → A** and **S → B**: + `CommunicationTransientString("The items are being traded")`. + - Escrow: for every guid in `A.ItemsInTradeWindow`, + `TryRemoveFromInventoryWithNetworking(..., RemoveFromInventoryAction.TradeItem)` + or `TryDequipObjectWithNetworking(..., DequipObjectAction.TradeItem)` — + this emits the **ordinary inventory wire family**, not trade-specific + opcodes: from a pack slot → + `GameMessagePublicUpdateInstanceID(Container→Invalid)` + + `GameMessagePrivateUpdatePropertyInt(EncumbranceVal)` + + `GameMessageDeleteObject(item)` (`Player_Inventory.cs:217-247`); from an + equipped slot → `GameMessagePublicUpdateInstanceID(Wielder→Invalid)` + + `GameMessagePublicUpdatePropertyInt(CurrentWieldedLocation=0)` + + `GameMessagePickupEvent(item)` + `GameMessageSound(UnwieldObject)` + + `GameMessageDeleteObject(item)` (`Player_Inventory.cs:396-421`). Mirror + for B's items. + - After a **0.5s `ActionChain` delay**: deliver each escrowed item to its + new owner via `TryCreateInInventoryWithNetworking` — emits + `GameMessageCreateObject(item)` (+ `GameEventViewContents` and child + `GameMessageCreateObject`s if the item is itself a container) + + `GameEventItemServerSaysContainId(item, container)` + + `GameMessagePrivateUpdatePropertyInt(EncumbranceVal)` + (`Player_Inventory.cs:90-114`). + - **S → A** and **S → B**: `WeenieError.TradeComplete (0x0529)` via + `GameEventWeenieError`. + - `TradeTransferInProgress=false`, `IsBusy=false` on both; + `SaveBiotasInParallel` persists the moved items; then + `HandleActionResetTrade` runs for both sides (**S → A**, **S → B**: + `ResetTrade(0x0205)` `(whoReset=own guid)`) — this clears + `ItemsInTradeWindow`/`TradeAccepted` but leaves `IsTrading`/`TradePartner` + intact, so the window stays open, empty, for another round. + +### Decline path + +**Either side → S**: `DeclineTrade(0x01FB)` (no fields). +`HandleActionDeclineTrade` (`:307-323`): if `TradeTransferInProgress`, no-op +(mid-swap declines are ignored); else clears the sender's `TradeAccepted`; +**S → sender**: `DeclineTrade(0x0203)` `(whoDeclined=sender.Guid)` + +`CommunicationTransientString("Trade confirmation failed...")`; **S → +partner**: identical pair. The window stays open with items still present — +decline only clears acceptance, it does not reset or close. + +### Reset path (client-initiated "clear my offered items") + +**A → S**: `ResetTrade(0x0204)` (no fields). `GameActionResetTrade.Handle` +resolves `target = PlayerManager.GetOnlinePlayer(A.TradePartner)`; if found, +calls `A.HandleActionResetTrade(A.Guid)` **and** +`target.HandleActionResetTrade(A.Guid)` (`GameActionResetTrade.cs:14-19`). +Both calls run the same body (`Player_Trade.cs:177-186`): no-op if +`TradeTransferInProgress`; else clears `ItemsInTradeWindow` and +`TradeAccepted` for **whichever player's session the call executes under**, +then **S → that session**: `ResetTrade(0x0205)` `(whoReset=A.Guid)`. Net +effect: A's own window is cleared and A gets `ResetTrade`; B's window is +also cleared (same `whoReset=A.Guid` payload) and B gets `ResetTrade` too — +i.e. one player resetting clears **both** sides' offered-item lists, not +just their own. (Confirmed by reading the two-call site directly; this is +easy to misread as "reset only mine.") + +### Close path + +**A → S**: `CloseTradeNegotiations(0x01F7)` (no fields). +`GameActionCloseTradeNegotiations.Handle` resolves B via +`A.TradePartner`, then calls `A.HandleActionCloseTradeNegotiations()` and +`B.HandleActionCloseTradeNegotiations()` (`:12-17`). +`HandleActionCloseTradeNegotiations(endTradeReason=Normal)` +(`Player_Trade.cs:102-114`): no-op if `TradeTransferInProgress` (can't close +mid-swap); else `IsTrading=false`, `TradeAccepted=false`, +`TradeTransferInProgress=false`, `ItemsInTradeWindow.Clear()`, +`TradePartner=Invalid`; **S → that session**: `CloseTrade(0x01FF)` +`(reason)` + `WeenieError.TradeClosed (0x0451)`. Runs for both A and B, each +getting their own `CloseTrade`+`TradeClosed` pair. Items still sitting in +the window at close time are simply left in the owner's inventory/equipped +slot — closing never moves anything (only `FinalizeTrade`'s accept-accept +path does). + +There is also a forced-close path: `HandleActionTradeSwitchToCombatMode` +(`:325-340`) — if a trading player enters combat mode, both sides get +`WeenieError.TradeNonCombatMode (0x0455)` then +`HandleActionCloseTradeNegotiations(EndTradeReason.EnteredCombat)`. Not +triggered by a dedicated action opcode; it's called from wherever ACE's +combat-mode-change action handler lives (not opened in this pass — grep +`GameActionChangeCombatMode.cs` if wiring this). + +### Failure paths + +- **Distance**: `WeenieError.TradeMaxDistanceExceeded (0x044E)` — initiator's + `CreateMoveToChain` callback failed (target moved away / unreachable) + before any `RegisterTrade` is sent. No trade session created. +- **Already trading**: `WeenieError.TradeAlreadyTrading (0x044F)` — either + side already `IsTrading`. +- **Non-combat required**: `WeenieError.TradeNonCombatMode (0x0455)` — either + side in combat mode at open time, or entering combat mid-trade (above). +- **Ignoring requests**: `WeenieError.TradeIgnoringRequests (0x044C)` — target + has `CharacterOption.IgnoreAllTradeRequests` set. +- **Attuned/pet item**: `AddToTrade` refused — + `GameEventCommunicationTransientString("You cannot trade that!")` (or the + pet-specific string) + `TradeFailure(0x0207)` with + `reason=WeenieError.AttunedItem`. Item never enters `ItemsInTradeWindow`. +- **Unique-item cap**: `AddToTrade` refused when + `wo.IsUniqueOrContainsUnique && !target.CheckUniques(...)` — + `TradeFailure(0x0207)` with `reason=WeenieError.None` (ACE leaves a `// + TODO` comment at `Player_Trade.cs:156` questioning whether this should be + `TooManyUniqueItems` or a `WeenieErrorWithString` — as shipped it's the + generic `None` reason, i.e. the client gets a failure with no readable + cause). +- **Busy at finalize**: `VerifyTrade_BusyState` fails — + `CommunicationTransientString` (busy-side/other-side wording) on both + + `ClearTradeAcceptance(0x0208)` on both. Window stays open with items + in place; nothing moves. +- **Inventory can't accept at finalize**: `VerifyTrade_Inventory` fails + (encumbrance or free-slot check via `CanAddToInventory`) — + `CommunicationTransientString` (encumbered/pack-space wording, correctly + attributed to whichever side is the actual blocker) on both + + `ClearTradeAcceptance(0x0208)` on both. Window stays open, items in + place. +- **Item vanished before finalize** (e.g. someone else picked it up / + it was consumed by another concurrent action): `GetItemsInTradeWindow` + returns false for that side inside `VerifyTrade_Inventory`, which routes + into `HandleActionDeclineTrade` for the affected side — same wire as the + manual decline path (`DeclineTrade(0x0203)` + transient string to both). + +## ACE quirks / landmines vs. retail + +1. **`RegisterTrade` sends the wrong initiator guid.** Both S→A and S→B + `RegisterTrade` events carry `(initiator=tradePartner.Guid, + partner=tradePartner.Guid)` — i.e. the *non-initiator's* guid in both + slots, always (`Player_Trade.cs:80`, `:98`). Retail's + `Trade::Register(partnerGuid, stamp)` (decomp `0x005b9ef0`) only takes a + single `partner` argument and separately tracks `_initiator` elsewhere + in the `Trade` object, so the client is presumably reading + `initiator`/`partner` fields that ACE fills identically and incorrectly. + Whether any retail client logic actually branches on `RegisterTrade`'s + `initiator` field (vs. deriving initiator status locally) is unverified + in this pass — flag before relying on that field client-side. +2. **`OpenTrade (0x01FE)` is never sent.** Retail's dispatcher and + `Handle_Trade__Recv_OpenTrade` exist and are wired + (`DispatchUI_Recv_OpenTrade` @ `0x006acef0`), but no ACE `GameEvent*` + class emits `GameEventType.OpenTrade`. `RegisterTrade` is what actually + establishes the session; whatever retail UI behavior was gated on the + separate `OpenTrade` notice (a `partnerGuid`-only payload) never fires + against ACE. +3. **`RemoveFromTrade (0x0201)` is never sent.** Retail supports removing a + single item from the trade window without clearing the whole thing + (`Handle_Trade__Recv_RemoveFromTrade`, mode 1 = remove one, mode 2 = + remove matching quantity, calling `Trade::RemoveItem`). ACE has **no + server-side action to remove a single item** either — there is no + `GameActionType` between `AddToTrade (0x1F8)` and `AcceptTrade (0x1FA)` + reserved for it beyond the opcode gap at `0x1F9`. The only way to change + an offer on ACE is `ResetTrade (0x0204)`, which clears the **entire** + window on **both sides** (see Reset path above), not per-item removal. + Any acdream UI that lets a player "un-drag" a single item from the trade + window has nothing to send — either fake it client-side (visually pull + the item back, no wire message, and let the player re-add the rest) or + accept it maps to a full reset. +4. **`AcceptTrade`'s client-echoed state is entirely ignored server-side.** + The client packs its full local `Trade` snapshot — partner guid, a + double timestamp, status, initiator guid, both accept flags, AND the two + variable-length item lists it believes are in play — but + `GameActionAcceptTrade.Handle` (`:11-18`) reads all six fixed fields into + locals and then calls `session.Player.HandleActionAcceptTrade()` with + **zero arguments**; none of the parsed values are used. ACE derives + accept state purely from its own `TradeAccepted` bool and the two + `ItemsInTradeWindow` sets. This means a desynced client (stale local + `Trade` object) cannot corrupt the server's view, but also means ACE + does zero cross-validation against what the client thinks is in the + trade — divergence would only surface as a visual mismatch on the + client, not a security issue. +5. **`RegisterTrade`'s stamp and `AddToTrade`'s slot are hardcoded zero.** + ACE always writes `0L` for the trade timestamp + (`GameEventRegisterTrade.cs:12`) and `0` for the trade-window slot index + (`GameEventAddToTrade.cs:12`). If retail client UI ever used the + slot field to place an item visually at a specific grid position rather + than append-ordering, that positioning info is lost against ACE — items + would need to rely on arrival order instead. +6. **Distance/approach gate only applies to the initiator.** `CreateMoveToChain` + (auto-walk-to-target) only runs in the `initiator=true` branch + (`Player_Trade.cs:70-84`); the responding player's side + (`initiator=false`, `:85-99`) never re-checks distance and starts the + session unconditionally once the initiator's chain succeeds. There is no + second distance check at `AddToTrade` or `AcceptTrade` time — a trade + session, once open, has no live proximity requirement to keep offering + or accepting items even if the players walk apart afterward. +7. **`ClearTradeAcceptance (0x0208)` carries no identifying field.** Unlike + every other trade event, it has no guid/payload at all + (`GameEventClearTradeAcceptance.cs`) — the client must infer "this + applies to my own trade window" purely from receiving it on its own + session, since there's nothing to disambiguate self vs. partner (not + needed: it's always sent to the session whose acceptance was cleared). +8. **Attuned/unique-item refusals leave the item untouched, no + `RemoveFromTrade` needed** — since the item is refused before ever being + added to `ItemsInTradeWindow`, there is nothing to roll back client-side + beyond the `TradeFailure` notice. diff --git a/docs/research/2026-08-14-trade-laneC-seams.md b/docs/research/2026-08-14-trade-laneC-seams.md new file mode 100644 index 00000000..84fede93 --- /dev/null +++ b/docs/research/2026-08-14-trade-laneC-seams.md @@ -0,0 +1,440 @@ +# 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?` 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` 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 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. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 75eacc76..194eeb91 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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, diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 3ffeea3f..0b0b44d4 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -33,6 +33,13 @@ internal sealed record LiveSessionCommandBindings( Action RemoveFriend, Action ClearFriends, Action RequestLegacyFriends, + // Secure trade (2026-08-14) — the CM_Trade senders. + Action OpenTradeNegotiations, + Action CloseTradeNegotiations, + Action AddToTrade, + Action AcceptTrade, + Action DeclineTrade, + Action ResetTrade, Action ModifyCharacterSquelch, Action ModifyAccountSquelch, Action 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( command => SendIfActive(() => bindings.AddFriend(command.Name))); + commands.Register( + command => SendIfActive(() => + bindings.OpenTradeNegotiations(command.PartnerGuid))); + commands.Register( + _ => SendIfActive(bindings.CloseTradeNegotiations)); + commands.Register( + command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid))); + commands.Register( + command => SendIfActive(() => bindings.AcceptTrade( + command.PartnerGuid, + command.SelfAccepted, + command.PartnerAccepted))); + commands.Register( + _ => SendIfActive(bindings.DeclineTrade)); + commands.Register( + _ => SendIfActive(bindings.ResetTrade)); commands.Register( command => SendIfActive(() => bindings.RemoveFriend(command.CharacterId))); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 4e21d481..174dfa3d 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -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, diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 29555b95..1cb81c22 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -182,6 +182,16 @@ public sealed class ItemInteractionController : IDisposable public event Action? StateChanged; + /// + /// Retail's two secure-trade open paths surface here for the trade UI: + /// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player + /// (DetermineUseResult @ 0x00588460 result 5), non-zero for + /// drag-item-onto-player with the DragItemOnPlayerOpensSecureTrade + /// option (AttemptPlaceIn3D @ 0x00588600). The subscriber + /// (SecureTradeUiController) owns the open/stage sequencing. + /// + public event Action? SecureTradeRequested; + /// /// Retail CM_Item::SendNotice_ShowPendingInPlayer: 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, diff --git a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs new file mode 100644 index 00000000..34235ef9 --- /dev/null +++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs @@ -0,0 +1,293 @@ +using System.Numerics; +using AcDream.Core.Items; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported retail gmSecureTradeUI layout +/// (LayoutDesc 0x2100000D, root 0x1000007A) to +/// 's view. No panel geometry is synthesized: +/// every control is the authored element. +/// +/// +/// Retail references (lane A, docs/research/2026-08-14-trade-laneA-ui.md): +/// gmSecureTradeUI::PostInit @ 0x004CA160 binds exactly these ids; +/// ListenToElementMessage @ 0x004CAE80 reacts to the Trade button +/// (0x10000086), Clear All (0x1000008A), and the close X (0x1000008B); +/// RecvNotice_RegisterTrade @ 0x004CA5C0 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): +/// ItemHolder::DetermineUseResult @ 0x00588460 result 5 (Use on a +/// player) and ItemHolder::AttemptPlaceIn3D @ 0x00588600's +/// DragItemOnPlayerOpensSecureTrade branch → both surface here through +/// (the ItemInteractionController event), +/// mirroring ClientTradeSystem::AttemptToOpenTradeNegotiations +/// @ 0x0056DEE0 / AttemptToTradeItem @ 0x0056DF80 — the latter's +/// "queue the dragged item until the window registers" is the pending-stage +/// latch consumed in . +/// +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; + + /// The authored partner-status accept cue (probe: element + /// 0x1000007F states '', 'Highlight', 'Ghosted'). + private const string AcceptedState = "Highlight"; + + public sealed record Bindings( + IRuntimeTradeView Trade, + ClientObjectTable Objects, + Func ResolveIcon, + Action OpenTrade, + Action CloseTrade, + Action AddToTrade, + Action AcceptTrade, + Action DeclineTrade, + Action ResetTrade, + Action 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); + } + + /// + /// 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 + /// (AttemptToTradeItem's open-trade branch); otherwise open + /// negotiations and latch the item until RegisterTrade arrives. + /// + 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); + } + + /// Applies the latest owner snapshot (revision-gated). + 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; + } + + /// Drops on the SELF grid stage the dragged inventory item + /// (retail's AcceptDragObject → AddToTrade path). + 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); + } + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 4d814eb6..0d81bb39 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -283,7 +283,10 @@ public sealed record SocialRuntimeBindings( Func AllegianceSwear, Func AllegianceBreak, Func AllegianceKick, - Func AllegianceSetUpdateSubscription); + Func 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."); } + /// The mounted secure-trade window's controller — null until + /// runs (or when the trade view/layout is + /// unavailable). + 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(), diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 7af84385..42da88bf 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -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"; diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index bb348818..395ec406 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -112,7 +112,19 @@ public static class GameEventWiring Action? onAllegianceUpdate = null, Action? onAllegianceUpdateDone = null, Action? onAllegianceUpdateAborted = null, - Action? onAllegianceLoginNotification = null) + Action? onAllegianceLoginNotification = null, + // Secure trade (2026-08-14): the same Runtime-owned delegate-hole + // shape as fellowship above. RuntimeTradeState is the consumer; + // docs/research/2026-08-14-trade-laneB-wire.md is the wire SSOT. + Action? onTradeRegister = null, + Action? onTradeClose = null, + Action? onTradeAdd = null, + Action? onTradeRemove = null, + Action? onTradeAccept = null, + Action? onTradeDecline = null, + Action? onTradeReset = null, + Action? onTradeFailure = null, + Action? onTradeClearAcceptance = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -316,6 +328,80 @@ public static class GameEventWiring }); } + // ── Secure trade (0x01FD–0x0208) ────────────────────────── + if (onTradeRegister is not null) + { + registrar.Register(GameEventType.RegisterTrade, e => + { + var p = GameEvents.ParseRegisterTrade(e.Payload.Span); + if (p is not null) onTradeRegister(p.Value); + }); + } + if (onTradeClose is not null) + { + registrar.Register(GameEventType.CloseTrade, e => + { + var p = GameEvents.ParseCloseTrade(e.Payload.Span); + if (p is not null) onTradeClose(p.Value); + }); + } + if (onTradeAdd is not null) + { + registrar.Register(GameEventType.AddToTrade, e => + { + var p = GameEvents.ParseAddToTrade(e.Payload.Span); + if (p is not null) onTradeAdd(p.Value); + }); + } + if (onTradeRemove is not null) + { + // ACE never emits 0x0201; registered defensively for the retail + // handler's sake (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00). + registrar.Register(GameEventType.RemoveFromTrade, e => + { + var p = GameEvents.ParseRemoveFromTrade(e.Payload.Span); + if (p is not null) onTradeRemove(p.Value); + }); + } + if (onTradeAccept is not null) + { + registrar.Register(GameEventType.AcceptTrade, e => + { + var p = GameEvents.ParseAcceptTrade(e.Payload.Span); + if (p is not null) onTradeAccept(p.Value); + }); + } + if (onTradeDecline is not null) + { + registrar.Register(GameEventType.DeclineTrade, e => + { + var p = GameEvents.ParseDeclineTrade(e.Payload.Span); + if (p is not null) onTradeDecline(p.Value); + }); + } + if (onTradeReset is not null) + { + registrar.Register(GameEventType.ResetTrade, e => + { + var p = GameEvents.ParseResetTrade(e.Payload.Span); + if (p is not null) onTradeReset(p.Value); + }); + } + if (onTradeFailure is not null) + { + registrar.Register(GameEventType.TradeFailure, e => + { + var p = GameEvents.ParseTradeFailure(e.Payload.Span); + if (p is not null) onTradeFailure(p.Value); + }); + } + if (onTradeClearAcceptance is not null) + { + // 0x0208 carries no payload (GameEventClearTradeAcceptance). + registrar.Register(GameEventType.ClearTradeAcceptance, _ => + onTradeClearAcceptance()); + } + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 96cf6b37..23545771 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -462,31 +462,98 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } - /// 0x0207 TradeFailure: server trade error code. - public static uint? ParseTradeFailure(ReadOnlySpan 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. + + /// 0x01FD RegisterTrade: (initiator, partner, stamp). Retail + /// Handle_Trade__Recv_RegisterTrade @ 0x0056E050. 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). + public readonly record struct RegisterTrade(uint Initiator, uint Partner, ulong Stamp); + + public static RegisterTrade? ParseRegisterTrade(ReadOnlySpan payload) + { + if (payload.Length < 16) return null; + return new RegisterTrade( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(8))); + } + + /// 0x01FF CloseTrade: end reason (Normal=1, EnteredCombat=2, + /// Canceled=0x51). Retail dispatch @ 0x006ACE90. + public static uint? ParseCloseTrade(ReadOnlySpan payload) { if (payload.Length < 4) return null; return BinaryPrimitives.ReadUInt32LittleEndian(payload); } - /// 0x0200 AddToTrade: (itemGuid, slotIndex). - public readonly record struct AddToTrade(uint ItemGuid, uint SlotIndex); + /// 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. + public readonly record struct AddToTrade(uint ItemGuid, uint Side, uint SlotIndex); public static AddToTrade? ParseAddToTrade(ReadOnlySpan 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))); + } + + /// 0x0201 RemoveFromTrade: (itemGuid, mode). Retail + /// Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00; ACE never + /// emits it (no per-item removal server-side) — parsed defensively. + public readonly record struct RemoveFromTrade(uint ItemGuid, uint Mode); + + public static RemoveFromTrade? ParseRemoveFromTrade(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new RemoveFromTrade( BinaryPrimitives.ReadUInt32LittleEndian(payload), BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); } - /// 0x0202 AcceptTrade: initiator guid. + /// 0x0202 AcceptTrade: who accepted (the client compares + /// against its own guid for self-vs-partner — retail dispatch + /// @ 0x006ACDF0). public static uint? ParseAcceptTrade(ReadOnlySpan payload) { if (payload.Length < 4) return null; return BinaryPrimitives.ReadUInt32LittleEndian(payload); } + /// 0x0203 DeclineTrade: who declined. + public static uint? ParseDeclineTrade(ReadOnlySpan payload) + { + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); + } + + /// 0x0205 ResetTrade: who reset. ACE clears BOTH sides' + /// staged items on either player's reset (lane B §quirks). + public static uint? ParseResetTrade(ReadOnlySpan payload) + { + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); + } + + /// 0x0207 TradeFailure: (itemGuid, WeenieError reason). Retail + /// Handle_Trade__Recv_TradeFailure @ 0x0056D990 removes the item + /// locally before showing the notice. + public readonly record struct TradeFailure(uint ItemGuid, uint Reason); + + public static TradeFailure? ParseTradeFailure(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new TradeFailure( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + } + /// /// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid). /// Retail anchor: CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0 diff --git a/src/AcDream.Core.Net/Messages/TradeRequests.cs b/src/AcDream.Core.Net/Messages/TradeRequests.cs new file mode 100644 index 00000000..ce7f4d53 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/TradeRequests.cs @@ -0,0 +1,110 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Secure-trade GameAction builders — retail's CM_Trade senders, +/// byte-checked against ACE's readers (the server acdream runs against). +/// +/// +/// Wire research: docs/research/2026-08-14-trade-laneB-wire.md — +/// ACE, the retail decomp, and holtburger's independent Rust implementation +/// agree on every field ACE implements. Retail senders: +/// Event_OpenTradeNegotiations @ 0x0056D300, +/// Event_CloseTradeNegotiations @ 0x0056D1E0, +/// Event_AddToTrade @ 0x0056D0D0, Event_AcceptTrade (packing +/// Trade::Pack @ 0x005B9FF0), Event_DeclineTrade @ 0x0056D270, +/// Event_ResetTrade @ 0x0056D3D0. 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). +/// +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; + + /// Open trade with — ACE walks + /// the initiator into range (CreateMoveToChain) before both sides + /// receive RegisterTrade. + 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); + + /// Stage an item. is ACE-ignored + /// (it always echoes slot 0); retail sends its grid slot. + 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; + } + + /// + /// Accept the current offer. The payload mirrors retail's + /// Trade::Pack fixed fields; ACE parses and then DISCARDS every + /// one of them (HandleActionAcceptTrade() takes zero arguments — + /// server state is fully self-derived), so the two trailing + /// PackableList<ContentProfile> item lists retail appends are sent + /// as zero-count lists here (register row AD-94). + /// + 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); + + /// Clear the trade window. ACE clears BOTH players' staged + /// items, not just the sender's (Player_Trade landmine — lane B §quirks). + 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); + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index a57eace0..6833825b 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2561,6 +2561,65 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); } + // ── Secure trade (docs/research/2026-08-14-trade-laneB-wire.md) ──────── + + /// Open secure trade with another player — retail + /// CM_Trade::Event_OpenTradeNegotiations @ 0x0056D300. + public void SendOpenTradeNegotiations(uint partnerGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildOpenTradeNegotiations(seq, partnerGuid)); + } + + /// Close the trade window — Event_CloseTradeNegotiations + /// @ 0x0056D1E0. + public void SendCloseTradeNegotiations() + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildCloseTradeNegotiations(seq)); + } + + /// Stage an item into the trade — Event_AddToTrade + /// @ 0x0056D0D0. + public void SendAddToTrade(uint itemGuid, uint tradeSlot = 0u) + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildAddToTrade(seq, itemGuid, tradeSlot)); + } + + /// Accept the current offer — Event_AcceptTrade packing + /// Trade::Pack @ 0x005B9FF0's fixed fields (ACE discards the + /// payload entirely; see TradeRequests.BuildAcceptTrade). + public void SendAcceptTrade( + uint partnerGuid, + double tradeStamp, + uint tradeStatus, + uint initiatorGuid, + bool initiatorAccepts, + bool partnerAccepts) + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildAcceptTrade( + seq, partnerGuid, tradeStamp, tradeStatus, + initiatorGuid, initiatorAccepts, partnerAccepts)); + } + + /// Withdraw a previous accept — Event_DeclineTrade + /// @ 0x0056D270. + public void SendDeclineTrade() + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildDeclineTrade(seq)); + } + + /// Clear the trade window — Event_ResetTrade @ 0x0056D3D0. + /// ACE clears BOTH sides' staged items (lane B §quirks). + public void SendResetTrade() + { + uint seq = NextGameActionSequence(); + SendGameAction(TradeRequests.BuildResetTrade(seq)); + } + /// /// Send retail GiveObjectRequest (0x00CD). Retail /// CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00 writes diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index da5f474a..0f452896 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -37,8 +37,10 @@ public enum GameRuntimeTeardownStage // Campaign FA slice FA2 (2026-08-12): the two sibling J-owners. FellowshipDisposed = 1 << 9, AllegianceDisposed = 1 << 10, - IdentityDisposed = 1 << 11, - EntityObjectsDisposed = 1 << 12, + // Secure trade (2026-08-14): third sibling J-owner, same shape. + TradeDisposed = 1 << 11, + IdentityDisposed = 1 << 12, + EntityObjectsDisposed = 1 << 13, Complete = HostLeasesReleased | EventsDetached @@ -51,6 +53,7 @@ public enum GameRuntimeTeardownStage | CommunicationDisposed | FellowshipDisposed | AllegianceDisposed + | TradeDisposed | IdentityDisposed | EntityObjectsDisposed, } @@ -94,6 +97,7 @@ internal enum GameRuntimeConstructionPoint CommunicationCreated, FellowshipCreated, AllegianceCreated, + TradeCreated, MovementCreated, ActionsCreated, EnvironmentCreated, @@ -111,6 +115,7 @@ internal sealed class GameRuntimeConstructionContext public RuntimeCommunicationState? Communication { get; set; } public RuntimeFellowshipState? Fellowship { get; set; } public RuntimeAllegianceState? Allegiance { get; set; } + public RuntimeTradeState? Trade { get; set; } public RuntimeLocalPlayerMovementState? Movement { get; set; } public RuntimeActionState? Actions { get; set; } public GameRuntimeEventHub? Events { get; set; } @@ -126,7 +131,7 @@ public sealed class GameRuntime IRuntimeEventSource, IDisposable { - private const int TeardownStageCount = 13; + private const int TeardownStageCount = 14; private readonly object _lifetimeGate = new(); private readonly Dictionary _hostLeases = []; @@ -248,6 +253,16 @@ public sealed class GameRuntime context, faultInjection); + // Secure trade (2026-08-14): third sibling J-owner — + // session-scoped like fellowship (a disconnect closes the trade + // server-side), clears at every generation reset. + context.Trade = new RuntimeTradeState(); + construction.Own(context.Trade); + Fault( + GameRuntimeConstructionPoint.TradeCreated, + context, + faultInjection); + context.Movement = new RuntimeLocalPlayerMovementState(); // Campaign CH slice CH2: local jump refusals (CommenceJump/ // DoJump's WeenieError family — research doc §4.2/§6.4) reach @@ -302,7 +317,8 @@ public sealed class GameRuntime context.Character, context.PlayerIdentity, context.Fellowship, - context.Allegiance); + context.Allegiance, + context.Trade); context.Movement.AttachPhysicsPublication( new RuntimeLocalPlayerPhysicsPublicationState( @@ -357,6 +373,7 @@ public sealed class GameRuntime CommunicationOwner = context.Communication; FellowshipOwner = context.Fellowship; AllegianceOwner = context.Allegiance; + TradeOwner = context.Trade; MovementOwner = context.Movement; ActionOwner = context.Actions; EnvironmentOwner = environment; @@ -462,6 +479,9 @@ public sealed class GameRuntime public RuntimeCommunicationState CommunicationOwner { get; } public RuntimeFellowshipState FellowshipOwner { get; } public RuntimeAllegianceState AllegianceOwner { get; } + + /// Secure trade (2026-08-14): third sibling J-owner. + public RuntimeTradeState TradeOwner { get; } public RuntimeActionState ActionOwner { get; } public RuntimeLocalPlayerMovementState MovementOwner { get; } internal RuntimeLocalPlayerPhysicsPublicationState @@ -507,6 +527,8 @@ public sealed class GameRuntime public IRuntimeChatView Chat => CommunicationOwner.View; public IRuntimeFellowshipView Fellowship => FellowshipOwner.View; public IRuntimeAllegianceView Allegiance => AllegianceOwner.View; + + public IRuntimeTradeView Trade => TradeOwner.View; public IRuntimeActionView Actions => ActionOwner.View; public IRuntimeMovementView Movement => MovementOwner.View; public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner; @@ -607,7 +629,8 @@ public sealed class GameRuntime ActionOwner, MovementOwner, FellowshipOwner, - AllegianceOwner), + AllegianceOwner, + TradeOwner), EnvironmentOwner.CaptureOwnership(), TransitOwner.CaptureOwnership(), GenerationReset.CaptureSnapshot(), @@ -733,16 +756,22 @@ public sealed class GameRuntime 9 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.FellowshipDisposed & ~GameRuntimeTeardownStage.AllegianceDisposed + & ~GameRuntimeTeardownStage.TradeDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 10 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.AllegianceDisposed + & ~GameRuntimeTeardownStage.TradeDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 11 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.TradeDisposed & ~GameRuntimeTeardownStage.IdentityDisposed & ~GameRuntimeTeardownStage.EntityObjectsDisposed, 12 => GameRuntimeTeardownStage.Complete + & ~GameRuntimeTeardownStage.IdentityDisposed + & ~GameRuntimeTeardownStage.EntityObjectsDisposed, + 13 => GameRuntimeTeardownStage.Complete & ~GameRuntimeTeardownStage.EntityObjectsDisposed, _ => GameRuntimeTeardownStage.Complete, }; @@ -794,9 +823,12 @@ public sealed class GameRuntime AllegianceOwner.Dispose(); return AllegianceOwner.CaptureOwnership().IsConverged; case 11: + TradeOwner.Dispose(); + return TradeOwner.CaptureOwnership().IsConverged; + case 12: PlayerIdentity.Dispose(); return PlayerIdentity.CaptureOwnership().IsConverged; - case 12: + case 13: EntityObjects.Dispose(); return EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged; @@ -819,8 +851,9 @@ public sealed class GameRuntime 8 => CommunicationOwner.CaptureOwnership().IsConverged, 9 => FellowshipOwner.CaptureOwnership().IsConverged, 10 => AllegianceOwner.CaptureOwnership().IsConverged, - 11 => PlayerIdentity.CaptureOwnership().IsConverged, - 12 => EntityObjects.CaptureOwnership().IsConverged + 11 => TradeOwner.CaptureOwnership().IsConverged, + 12 => PlayerIdentity.CaptureOwnership().IsConverged, + 13 => EntityObjects.CaptureOwnership().IsConverged && EntityObjects.Physics.CaptureOwnership().IsConverged, _ => true, }; diff --git a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs index 771fdd58..a648104f 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs @@ -11,9 +11,11 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot( RuntimeCommunicationOwnershipSnapshot Communication, RuntimeActionOwnershipSnapshot Actions, RuntimeLocalMovementOwnershipSnapshot Movement, - // Campaign FA slice FA2 (2026-08-12): the two sibling J-owners. + // Campaign FA slice FA2 (2026-08-12): the two sibling J-owners; + // secure trade (2026-08-14) is the third. RuntimeFellowshipOwnershipSnapshot Fellowship, - RuntimeAllegianceOwnershipSnapshot Allegiance) + RuntimeAllegianceOwnershipSnapshot Allegiance, + RuntimeTradeOwnershipSnapshot Trade) { public bool IsConverged => Inventory.IsConverged @@ -22,7 +24,8 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot( && Actions.IsConverged && Movement.IsConverged && Fellowship.IsConverged - && Allegiance.IsConverged; + && Allegiance.IsConverged + && Trade.IsConverged; } public static class RuntimeGameplayOwnership @@ -34,7 +37,8 @@ public static class RuntimeGameplayOwnership RuntimeActionState actions, RuntimeLocalPlayerMovementState movement, RuntimeFellowshipState fellowship, - RuntimeAllegianceState allegiance) + RuntimeAllegianceState allegiance, + RuntimeTradeState trade) { ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(character); @@ -43,6 +47,7 @@ public static class RuntimeGameplayOwnership ArgumentNullException.ThrowIfNull(movement); ArgumentNullException.ThrowIfNull(fellowship); ArgumentNullException.ThrowIfNull(allegiance); + ArgumentNullException.ThrowIfNull(trade); return new RuntimeGameplayOwnershipSnapshot( inventory.CaptureOwnership(), character.CaptureOwnership(), @@ -50,6 +55,7 @@ public static class RuntimeGameplayOwnership actions.CaptureOwnership(), movement.CaptureOwnership(), fellowship.CaptureOwnership(), - allegiance.CaptureOwnership()); + allegiance.CaptureOwnership(), + trade.CaptureOwnership()); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs b/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs new file mode 100644 index 00000000..ab61ba74 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs @@ -0,0 +1,307 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Gameplay; + +public readonly record struct RuntimeTradeOwnershipSnapshot( + bool IsDisposed, + bool IsOpen, + int StagedItemCount) +{ + public bool IsConverged => + IsDisposed + && !IsOpen + && StagedItemCount == 0; +} + +/// One player's staged-item side of the trade window. +public enum RuntimeTradeSide : uint +{ + Self = 1u, + Partner = 2u, +} + +/// Immutable poll snapshot of the whole trade. +public readonly record struct RuntimeTradeSnapshot( + long Revision, + bool IsOpen, + uint PartnerGuid, + bool SelfAccepted, + bool PartnerAccepted, + int SelfItemCount, + int PartnerItemCount, + uint LastFailureItemGuid, + uint LastFailureReason); + +public interface IRuntimeTradeView +{ + RuntimeTradeSnapshot Snapshot { get; } + + /// Materialized staged-item guids for one side, in stage order. + IReadOnlyList GetItems(RuntimeTradeSide side); +} + +/// +/// Canonical presentation-independent owner for the secure-trade window — +/// retail's ClientTradeSystem/Trade state, session-scoped like +/// (a disconnect closes the trade +/// server-side, so this clears at every generation reset). +/// +/// +/// Wire SSOT: docs/research/2026-08-14-trade-laneB-wire.md; retail UI +/// truth: docs/research/2026-08-14-trade-laneA-ui.md. Assembled from +/// the 0x01FD–0x0208 event family. ACE landmines honored here: +/// RegisterTrade's initiator/partner fields BOTH carry the non-self player's +/// guid (the true initiator is never on the wire — Player_Trade.cs:80,98), +/// so derives the partner as "whichever guid is +/// not mine, else either"; ResetTrade clears BOTH sides' items regardless of +/// who reset. Consumers poll 's monotonic revision — no +/// push event, the same D2 discipline as fellowship. +/// +public sealed class RuntimeTradeState : IDisposable +{ + private readonly object _gate = new(); + private readonly List _selfItems = []; + private readonly List _partnerItems = []; + private bool _isOpen; + private uint _partnerGuid; + private bool _selfAccepted; + private bool _partnerAccepted; + private uint _lastFailureItemGuid; + private uint _lastFailureReason; + private long _revision; + private bool _disposed; + + public RuntimeTradeState() => View = new TradeView(this); + + public IRuntimeTradeView View { get; } + + public bool IsDisposed + { + get { lock (_gate) return _disposed; } + } + + /// + /// 0x01FD RegisterTrade — the window opens on BOTH clients + /// (Handle_Trade__Recv_RegisterTrade @ 0x0056E050). Clears any + /// stale staged state from a previous trade. + /// + public void ApplyRegister(GameEvents.RegisterTrade update, uint selfGuid) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _isOpen = true; + _partnerGuid = update.Initiator != selfGuid && update.Initiator != 0u + ? update.Initiator + : update.Partner; + _selfItems.Clear(); + _partnerItems.Clear(); + _selfAccepted = false; + _partnerAccepted = false; + _lastFailureItemGuid = 0u; + _lastFailureReason = 0u; + Bump(); + } + } + + /// 0x01FF CloseTrade — full teardown on either side's close + /// (reason recorded nowhere; retail closes the panel outright). + public void ApplyClose() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ClearLocked(); + } + } + + /// 0x0200 AddToTrade — Side 1 = this client's own offer echo, + /// Side 2 = the partner staged an item. ACE's slot is always 0; stage + /// order is arrival order (retail's grid does the same against ACE). + public void ApplyAdd(GameEvents.AddToTrade update) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + List items = update.Side == (uint)RuntimeTradeSide.Partner + ? _partnerItems + : _selfItems; + if (!items.Contains(update.ItemGuid)) + items.Add(update.ItemGuid); + // Staging changes invalidate prior acceptance server-side (ACE + // re-arms via ClearTradeAcceptance; mirrored defensively here so + // a dropped 0x0208 cannot leave a stale green check). + _selfAccepted = false; + _partnerAccepted = false; + Bump(); + } + } + + /// 0x0201 RemoveFromTrade — ACE never emits it; honored + /// defensively for the retail handler's shape + /// (Handle_Trade__Recv_RemoveFromTrade @ 0x0056DC00). + public void ApplyRemove(GameEvents.RemoveFromTrade update) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + bool removed = _selfItems.Remove(update.ItemGuid); + removed |= _partnerItems.Remove(update.ItemGuid); + if (removed) Bump(); + } + } + + /// 0x0202 AcceptTrade — who accepted; compared against the + /// local player exactly like retail's dispatch @ 0x006ACDF0. + public void ApplyAccept(uint whoAccepted, uint selfGuid) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + if (whoAccepted == selfGuid) _selfAccepted = true; + else _partnerAccepted = true; + Bump(); + } + } + + /// 0x0203 DeclineTrade — withdraws that side's acceptance. + public void ApplyDecline(uint whoDeclined, uint selfGuid) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + if (whoDeclined == selfGuid) _selfAccepted = false; + else _partnerAccepted = false; + Bump(); + } + } + + /// 0x0205 ResetTrade — ACE clears BOTH sides' staged items on + /// either player's reset (lane B §quirks), and acceptance with them. + /// The window stays open (a completed trade auto-resets this way). + public void ApplyReset() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + _selfItems.Clear(); + _partnerItems.Clear(); + _selfAccepted = false; + _partnerAccepted = false; + Bump(); + } + } + + /// 0x0207 TradeFailure — retail removes the refused item + /// locally before showing the notice + /// (Handle_Trade__Recv_TradeFailure @ 0x0056D990). + public void ApplyFailure(GameEvents.TradeFailure failure) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + _selfItems.Remove(failure.ItemGuid); + _partnerItems.Remove(failure.ItemGuid); + _lastFailureItemGuid = failure.ItemGuid; + _lastFailureReason = failure.Reason; + Bump(); + } + } + + /// 0x0208 ClearTradeAcceptance — both checks come down. + public void ApplyClearAcceptance() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_isOpen) return; + _selfAccepted = false; + _partnerAccepted = false; + Bump(); + } + } + + /// Generation reset / session teardown — full clear. + public void Clear() + { + lock (_gate) + { + if (_disposed) return; + ClearLocked(); + } + } + + public RuntimeTradeOwnershipSnapshot CaptureOwnership() + { + lock (_gate) + return new RuntimeTradeOwnershipSnapshot( + _disposed, + _isOpen, + _selfItems.Count + _partnerItems.Count); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + ClearLocked(); + _disposed = true; + } + } + + private void ClearLocked() + { + bool changed = _isOpen + || _selfItems.Count != 0 + || _partnerItems.Count != 0 + || _selfAccepted + || _partnerAccepted; + _isOpen = false; + _partnerGuid = 0u; + _selfItems.Clear(); + _partnerItems.Clear(); + _selfAccepted = false; + _partnerAccepted = false; + _lastFailureItemGuid = 0u; + _lastFailureReason = 0u; + if (changed) Bump(); + } + + private void Bump() => _revision++; + + private sealed class TradeView(RuntimeTradeState owner) : IRuntimeTradeView + { + public RuntimeTradeSnapshot Snapshot + { + get + { + lock (owner._gate) + return new RuntimeTradeSnapshot( + owner._revision, + owner._isOpen, + owner._partnerGuid, + owner._selfAccepted, + owner._partnerAccepted, + owner._selfItems.Count, + owner._partnerItems.Count, + owner._lastFailureItemGuid, + owner._lastFailureReason); + } + } + + public IReadOnlyList GetItems(RuntimeTradeSide side) + { + lock (owner._gate) + return side == RuntimeTradeSide.Partner + ? [.. owner._partnerItems] + : [.. owner._selfItems]; + } + } +} diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index cc17697e..786cc772 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -53,15 +53,22 @@ public enum RuntimeGenerationResetStage /// from the precedent it named). /// Allegiance = 13, - BeginEntityRetirement = 14, - RetireEntities = 15, - DrainHostProjection = 16, - CompleteCanonicalEntities = 17, - CompleteHostProjection = 18, - ChatIdentity = 19, - PlayerSnapshots = 20, - PlayerIdentity = 21, - Complete = 22, + /// + /// Secure trade (2026-08-14): the trade window is session-scoped — a + /// disconnect closes the trade server-side (ACE tears the negotiation + /// down with the session), so the third sibling J-owner clears here + /// beside its fellowship/allegiance precedents. + /// + Trade = 14, + BeginEntityRetirement = 15, + RetireEntities = 16, + DrainHostProjection = 17, + CompleteCanonicalEntities = 18, + CompleteHostProjection = 19, + ChatIdentity = 20, + PlayerSnapshots = 21, + PlayerIdentity = 22, + Complete = 23, } public readonly record struct RuntimeGenerationResetSnapshot( @@ -111,6 +118,7 @@ public sealed class RuntimeGenerationReset private readonly RuntimeLocalPlayerIdentityState _identity; private readonly RuntimeFellowshipState _fellowship; private readonly RuntimeAllegianceState _allegiance; + private readonly RuntimeTradeState _trade; private ResetState? _state; private RuntimeGenerationToken _lastCompletedGeneration; private bool _hasCompletedGeneration; @@ -127,7 +135,8 @@ public sealed class RuntimeGenerationReset RuntimeCharacterState character, RuntimeLocalPlayerIdentityState identity, RuntimeFellowshipState fellowship, - RuntimeAllegianceState allegiance) + RuntimeAllegianceState allegiance, + RuntimeTradeState trade) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _communication = communication @@ -147,6 +156,7 @@ public sealed class RuntimeGenerationReset ?? throw new ArgumentNullException(nameof(fellowship)); _allegiance = allegiance ?? throw new ArgumentNullException(nameof(allegiance)); + _trade = trade ?? throw new ArgumentNullException(nameof(trade)); } public RuntimeGenerationToken? ActiveRetiringGeneration => @@ -320,6 +330,9 @@ public sealed class RuntimeGenerationReset case RuntimeGenerationResetStage.Allegiance: Advance(state, _allegiance.ResetSession); break; + case RuntimeGenerationResetStage.Trade: + Advance(state, _trade.Clear); + break; case RuntimeGenerationResetStage.BeginEntityRetirement: _ = _entityObjects.BeginSessionClear(); state.Retirements = _entityObjects diff --git a/src/AcDream.Runtime/RuntimeSimulationOwnership.cs b/src/AcDream.Runtime/RuntimeSimulationOwnership.cs index 57b4ce46..d44ceca9 100644 --- a/src/AcDream.Runtime/RuntimeSimulationOwnership.cs +++ b/src/AcDream.Runtime/RuntimeSimulationOwnership.cs @@ -30,7 +30,8 @@ public static class RuntimeSimulationOwnership RuntimeActionState actions, RuntimeLocalPlayerMovementState movement, RuntimeFellowshipState fellowship, - RuntimeAllegianceState allegiance) + RuntimeAllegianceState allegiance, + RuntimeTradeState trade) { ArgumentNullException.ThrowIfNull(entityObjects); return new RuntimeSimulationOwnershipSnapshot( @@ -43,6 +44,7 @@ public static class RuntimeSimulationOwnership actions, movement, fellowship, - allegiance)); + allegiance, + trade)); } } diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 6182a01b..91605c7e 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -85,7 +85,10 @@ public sealed record LiveSocialSessionBindings( // compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md // §2.4 — "the established compatibility convention"). RuntimeFellowshipState? Fellowship = null, - RuntimeAllegianceState? Allegiance = null); + RuntimeAllegianceState? Allegiance = null, + // Secure trade (2026-08-14): the third sibling J-owner, same + // trailing/optional compatibility convention. + RuntimeTradeState? Trade = null); /// /// Owns every inbound subscription for one exact live session. Domain state @@ -281,6 +284,36 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting ? notice => allegianceLogin.ApplyLoginNotification( notice.CharacterGuid, notice.IsLoggedIn) + : null, + // Secure trade (2026-08-14): same conditional delegate-hole + // discipline. Self-vs-partner comparisons use the exact + // player guid the fellowship holes above already borrow. + onTradeRegister: social.Trade is { } tradeRegister + ? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid()) + : null, + onTradeClose: social.Trade is { } tradeClose + ? _ => tradeClose.ApplyClose() + : null, + onTradeAdd: social.Trade is { } tradeAdd + ? tradeAdd.ApplyAdd + : null, + onTradeRemove: social.Trade is { } tradeRemove + ? tradeRemove.ApplyRemove + : null, + onTradeAccept: social.Trade is { } tradeAccept + ? whoAccepted => tradeAccept.ApplyAccept(whoAccepted, inventory.PlayerGuid()) + : null, + onTradeDecline: social.Trade is { } tradeDecline + ? whoDeclined => tradeDecline.ApplyDecline(whoDeclined, inventory.PlayerGuid()) + : null, + onTradeReset: social.Trade is { } tradeReset + ? _ => tradeReset.ApplyReset() + : null, + onTradeFailure: social.Trade is { } tradeFailure + ? tradeFailure.ApplyFailure + : null, + onTradeClearAcceptance: social.Trade is { } tradeClear + ? tradeClear.ApplyClearAcceptance : null)); ConstructionCheckpoint(); diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs index 0b5aa190..992ae169 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs @@ -71,6 +71,38 @@ public sealed class PowerbarLayoutProbeTests } } + /// 2026-08-14 trade research: locate the LayoutDesc that authors + /// gmSecureTradeUI's element tree (lane A gave element ids 0x10000085-8B + /// but not the layout id) and dump it. + [Fact] + public void ProbeSecureTradeLayout() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1") + return; + + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + var strings = new DatStringResolver(dats); + + int scanned = 0; + for (uint layoutId = 0x21000000u; layoutId <= 0x210001FFu; layoutId++) + { + ElementInfo? root = LayoutImporter.ImportInfos(dats, layoutId); + if (root is null) + continue; + scanned++; + if (FindById(root, 0x10000088u) is null) + continue; + Console.WriteLine($"[pbprobe] SECURE TRADE LAYOUT = 0x{layoutId:X8}"); + DumpElement(strings, root, 0); + } + Console.WriteLine($"[pbprobe] scanned {scanned} layouts"); + } + private static ElementInfo? FindById(ElementInfo element, uint id) { if (element.Id == id) return element; diff --git a/tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs new file mode 100644 index 00000000..e568703e --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/TradeRequestsTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Golden-byte coverage for the secure-trade GameAction builders and inbound +/// parsers — the 2026-08-14 lane B wire tables (ACE + retail decomp + +/// holtburger three-way agreement). +/// +public sealed class TradeRequestsTests +{ + [Fact] + public void BuildOpenTradeNegotiations_WritesEnvelopeSequenceOpcodePartner() + { + byte[] body = TradeRequests.BuildOpenTradeNegotiations(7, 0x50001234u); + + Assert.Equal(16, body.Length); + Assert.Equal(TradeRequests.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0))); + Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(0x01F6u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x50001234u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Theory] + [InlineData(0x01F7u)] // CloseTradeNegotiations + [InlineData(0x01FBu)] // DeclineTrade + [InlineData(0x0204u)] // ResetTrade + public void EmptyBodiedActions_WriteEnvelopeSequenceOpcodeOnly(uint opcode) + { + byte[] body = opcode switch + { + 0x01F7u => TradeRequests.BuildCloseTradeNegotiations(3), + 0x01FBu => TradeRequests.BuildDeclineTrade(3), + _ => TradeRequests.BuildResetTrade(3), + }; + + Assert.Equal(12, body.Length); + Assert.Equal(TradeRequests.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0))); + Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void BuildAddToTrade_WritesItemAndSlot() + { + byte[] body = TradeRequests.BuildAddToTrade(5, 0x60000001u, 2u); + + Assert.Equal(20, body.Length); + Assert.Equal(0x01F8u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x60000001u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + } + + [Fact] + public void BuildAcceptTrade_WritesTradePackFixedFieldsAndTwoEmptyLists() + { + byte[] body = TradeRequests.BuildAcceptTrade( + gameActionSequence: 11, + partnerGuid: 0x50000B0Bu, + tradeStamp: 0d, + tradeStatus: 0u, + initiatorGuid: 0x5000A0A0u, + initiatorAccepts: true, + partnerAccepts: false); + + Assert.Equal(48, body.Length); + Assert.Equal(0x01FAu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x50000B0Bu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(0d, BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(16))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); + Assert.Equal(0x5000A0A0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28))); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(36))); + // Two zero-count PackableLists (AD-94 — ACE discards the payload). + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(40))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(44))); + } + + // ── Inbound parsers ───────────────────────────────────────────────────── + + [Fact] + public void ParseRegisterTrade_ReadsInitiatorPartnerStamp() + { + byte[] payload = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x50000002u); + BinaryPrimitives.WriteUInt64LittleEndian(payload.AsSpan(8), 0uL); + + var parsed = GameEvents.ParseRegisterTrade(payload); + Assert.NotNull(parsed); + Assert.Equal(0x50000001u, parsed!.Value.Initiator); + Assert.Equal(0x50000002u, parsed.Value.Partner); + Assert.Equal(0uL, parsed.Value.Stamp); + Assert.Null(GameEvents.ParseRegisterTrade(payload.AsSpan(0, 12))); + } + + [Fact] + public void ParseAddToTrade_ReadsGuidSideSlot() + { + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000009u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); + + var parsed = GameEvents.ParseAddToTrade(payload); + Assert.NotNull(parsed); + Assert.Equal(0x60000009u, parsed!.Value.ItemGuid); + Assert.Equal(2u, parsed.Value.Side); + Assert.Equal(0u, parsed.Value.SlotIndex); + Assert.Null(GameEvents.ParseAddToTrade(payload.AsSpan(0, 8))); + } + + [Fact] + public void ParseTradeFailure_ReadsGuidAndReason() + { + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x60000042u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x426u); + + var parsed = GameEvents.ParseTradeFailure(payload); + Assert.NotNull(parsed); + Assert.Equal(0x60000042u, parsed!.Value.ItemGuid); + Assert.Equal(0x426u, parsed.Value.Reason); + Assert.Null(GameEvents.ParseTradeFailure(payload.AsSpan(0, 4))); + } + + [Fact] + public void SingleGuidParsers_ReadTheGuid() + { + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000C0Cu); + + Assert.Equal(0x50000C0Cu, GameEvents.ParseAcceptTrade(payload)); + Assert.Equal(0x50000C0Cu, GameEvents.ParseDeclineTrade(payload)); + Assert.Equal(0x50000C0Cu, GameEvents.ParseResetTrade(payload)); + Assert.Equal(0x50000C0Cu, GameEvents.ParseCloseTrade(payload)); + } +} diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs index 8bf08678..d42f126d 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -239,6 +239,7 @@ public sealed class GameRuntimeTests GameRuntimeTeardownStage.CommunicationDisposed, GameRuntimeTeardownStage.FellowshipDisposed, GameRuntimeTeardownStage.AllegianceDisposed, + GameRuntimeTeardownStage.TradeDisposed, GameRuntimeTeardownStage.IdentityDisposed, GameRuntimeTeardownStage.EntityObjectsDisposed, ]; diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs index 241a8d48..c8e62246 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs @@ -20,6 +20,7 @@ public sealed class RuntimeGameplayOwnershipTests var movement = new RuntimeLocalPlayerMovementState(); var fellowship = new RuntimeFellowshipState(); var allegiance = new RuntimeAllegianceState(); + var trade = new RuntimeTradeState(); movement.Execute(RuntimeMovementCommand.ToggleRunLock); inventory.Transactions.IncrementBusyCount(); @@ -38,7 +39,8 @@ public sealed class RuntimeGameplayOwnershipTests actions, movement, fellowship, - allegiance); + allegiance, + trade); Assert.False(populated.IsConverged); movement.Dispose(); @@ -48,6 +50,7 @@ public sealed class RuntimeGameplayOwnershipTests inventory.Dispose(); fellowship.Dispose(); allegiance.Dispose(); + trade.Dispose(); entities.Dispose(); RuntimeSimulationOwnershipSnapshot retired = @@ -59,7 +62,8 @@ public sealed class RuntimeGameplayOwnershipTests actions, movement, fellowship, - allegiance); + allegiance, + trade); Assert.True(retired.IsConverged); Assert.True(retired.EntityObjects.IsDisposed); Assert.True(retired.Physics.IsDisposed); @@ -76,6 +80,7 @@ public sealed class RuntimeGameplayOwnershipTests var movement = new RuntimeLocalPlayerMovementState(); var fellowship = new RuntimeFellowshipState(); var allegiance = new RuntimeAllegianceState(); + var trade = new RuntimeTradeState(); movement.Execute(RuntimeMovementCommand.ToggleRunLock); inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); @@ -139,7 +144,8 @@ public sealed class RuntimeGameplayOwnershipTests actions, movement, fellowship, - allegiance); + allegiance, + trade); Assert.False(populated.IsConverged); Assert.Equal(1, populated.Inventory.ShortcutCount); @@ -161,6 +167,7 @@ public sealed class RuntimeGameplayOwnershipTests inventory.Dispose(); fellowship.Dispose(); allegiance.Dispose(); + trade.Dispose(); subscription.Dispose(); RuntimeGameplayOwnershipSnapshot retired = @@ -171,7 +178,8 @@ public sealed class RuntimeGameplayOwnershipTests actions, movement, fellowship, - allegiance); + allegiance, + trade); Assert.True(retired.IsConverged); Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); @@ -191,6 +199,7 @@ public sealed class RuntimeGameplayOwnershipTests var movement = new RuntimeLocalPlayerMovementState(); var fellowship = new RuntimeFellowshipState(); var allegiance = new RuntimeAllegianceState(); + var trade = new RuntimeTradeState(); inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); @@ -215,6 +224,7 @@ public sealed class RuntimeGameplayOwnershipTests communication.Dispose(); fellowship.Dispose(); allegiance.Dispose(); + trade.Dispose(); RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnership.Capture( @@ -224,7 +234,8 @@ public sealed class RuntimeGameplayOwnershipTests actions, movement, fellowship, - allegiance); + allegiance, + trade); Assert.True(retired.IsConverged); Assert.Equal(1, retired.Communication.DispatchFailureCount); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs new file mode 100644 index 00000000..5b59ce26 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeTradeStateTests.cs @@ -0,0 +1,142 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Secure-trade owner conformance (2026-08-14): the 0x01FD–0x0208 event +/// family against the lane B wire truths — including ACE's +/// wrong-initiator RegisterTrade, its both-sides ResetTrade, and retail's +/// remove-before-notice TradeFailure handling. +/// +public sealed class RuntimeTradeStateTests +{ + private const uint Self = 0x50000001u; + private const uint Partner = 0x50000002u; + private const uint ItemA = 0x60000001u; + private const uint ItemB = 0x60000002u; + + [Fact] + public void RegisterOpensAndDerivesPartnerDespiteAceGuidLandmine() + { + using var trade = new RuntimeTradeState(); + + // ACE sends initiator == partner == the non-self guid on BOTH sides. + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + + RuntimeTradeSnapshot snapshot = trade.View.Snapshot; + Assert.True(snapshot.IsOpen); + Assert.Equal(Partner, snapshot.PartnerGuid); + Assert.False(snapshot.SelfAccepted); + Assert.False(snapshot.PartnerAccepted); + } + + [Fact] + public void AddStagesPerSideAndDropsBothAcceptances() + { + using var trade = new RuntimeTradeState(); + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + trade.ApplyAccept(Self, Self); + trade.ApplyAccept(Partner, Self); + + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u)); + // Duplicate echo is a no-op stage-wise. + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + + RuntimeTradeSnapshot snapshot = trade.View.Snapshot; + Assert.Equal(1, snapshot.SelfItemCount); + Assert.Equal(1, snapshot.PartnerItemCount); + Assert.Equal([ItemA], trade.View.GetItems(RuntimeTradeSide.Self)); + Assert.Equal([ItemB], trade.View.GetItems(RuntimeTradeSide.Partner)); + // Staging invalidates prior acceptance (the ClearTradeAcceptance + // mirror). + Assert.False(snapshot.SelfAccepted); + Assert.False(snapshot.PartnerAccepted); + } + + [Fact] + public void AcceptDeclineTrackSelfVersusPartnerByGuid() + { + using var trade = new RuntimeTradeState(); + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + + trade.ApplyAccept(Partner, Self); + Assert.True(trade.View.Snapshot.PartnerAccepted); + Assert.False(trade.View.Snapshot.SelfAccepted); + + trade.ApplyAccept(Self, Self); + Assert.True(trade.View.Snapshot.SelfAccepted); + + trade.ApplyDecline(Partner, Self); + Assert.False(trade.View.Snapshot.PartnerAccepted); + Assert.True(trade.View.Snapshot.SelfAccepted); + } + + [Fact] + public void ResetClearsBothSidesButKeepsTheWindowOpen() + { + using var trade = new RuntimeTradeState(); + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + trade.ApplyAdd(new GameEvents.AddToTrade(ItemB, (uint)RuntimeTradeSide.Partner, 0u)); + trade.ApplyAccept(Self, Self); + + trade.ApplyReset(); + + RuntimeTradeSnapshot snapshot = trade.View.Snapshot; + Assert.True(snapshot.IsOpen); + Assert.Equal(0, snapshot.SelfItemCount); + Assert.Equal(0, snapshot.PartnerItemCount); + Assert.False(snapshot.SelfAccepted); + } + + [Fact] + public void FailureRemovesTheItemAndRecordsTheReason() + { + using var trade = new RuntimeTradeState(); + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + + trade.ApplyFailure(new GameEvents.TradeFailure(ItemA, 0x426u)); + + RuntimeTradeSnapshot snapshot = trade.View.Snapshot; + Assert.Equal(0, snapshot.SelfItemCount); + Assert.Equal(ItemA, snapshot.LastFailureItemGuid); + Assert.Equal(0x426u, snapshot.LastFailureReason); + } + + [Fact] + public void CloseAndGenerationClearConvergeTheLedger() + { + var trade = new RuntimeTradeState(); + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + + trade.ApplyClose(); + Assert.False(trade.View.Snapshot.IsOpen); + Assert.Equal(0, trade.View.Snapshot.SelfItemCount); + + trade.ApplyRegister(new GameEvents.RegisterTrade(Partner, Partner, 0uL), Self); + trade.Clear(); + Assert.False(trade.View.Snapshot.IsOpen); + + trade.Dispose(); + Assert.True(trade.CaptureOwnership().IsConverged); + } + + [Fact] + public void EventsBeforeRegisterAreIgnored() + { + using var trade = new RuntimeTradeState(); + + trade.ApplyAdd(new GameEvents.AddToTrade(ItemA, (uint)RuntimeTradeSide.Self, 0u)); + trade.ApplyAccept(Partner, Self); + trade.ApplyReset(); + + RuntimeTradeSnapshot snapshot = trade.View.Snapshot; + Assert.False(snapshot.IsOpen); + Assert.Equal(0, snapshot.SelfItemCount); + Assert.False(snapshot.PartnerAccepted); + } +}