From ffc73f80bf20a0207b9ae82167f0e7a1a5ffc13a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 12:34:31 +0200 Subject: [PATCH] fix: trade gate round 3 - the mount-time-captured dead command bus (ONE root cause for every dead interaction) + retail's Total Items caption The round-2 probes nailed it: the request seam fired for BOTH open paths (use AND drag - "drag-release pick" -> "drag-on-player" -> "request"), but no open-cmd, no wire-open, and no LiveCommandBus drop-warning ever printed. MountSecureTrade captured _bindings.Options.CommandBus() ONCE at mount time - the pre-session surface whose Publish routes into a null route silently. CommandBus is a Func for exactly this reason; the social mounts resolve it inside each lambda. Every trade command - open (use + drag), accept (the "unpressable" Trade button - the click FIRED, the publish died), Clear All, close, and drop-on-grid staging - died on that one captured bus. All six lambdas now resolve the Func per call. Also: ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable 0x004E8A23) and composed via ResolveTemplate - the count texts read retail's exact "Total Items: N". AD-95 RETIRED same-day. The pre-feature stub-toast test row (drag-on-player option-on expecting "Secure trade is not open.") now pins the SecureTradeRequested seam instead. App suite 4,991/3 skips. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../UI/Layout/SecureTradeUiController.cs | 15 ++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 39 +++++++++++++++---- .../Net/LiveSessionCommandRouterTests.cs | 6 +++ .../UI/ItemInteractionControllerTests.cs | 12 +++++- .../UI/Layout/PowerbarLayoutProbeTests.cs | 38 ++++++++++++++++++ 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0f4ab0d5..3960fc2a 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) — 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) +## 2. Adaptation (AD) — 73 active rows (AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; 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 @@ -188,7 +188,6 @@ readiness/requeue adaptation. See | 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/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs index 2cedd243..0a3168ed 100644 --- a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs +++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs @@ -63,7 +63,11 @@ public sealed class SecureTradeUiController : IRetainedPanelController // ItemListCellTemplate from the lists' own 0x1000000E cell-template // attribute (0x1000033A) — same recipe as the vendor strips. uint SelfEmptySlotSprite = 0u, - uint PartnerEmptySlotSprite = 0u); + uint PartnerEmptySlotSprite = 0u, + // ID_SecureTrade_TotalItemsLabel composed per count — probe-verified + // token-free (fragments ["Total Items: ", ""], one ITEMS variable); + // null falls back to the bare number. + Func? FormatTotalItems = null); private readonly Bindings _bindings; private readonly UiText? _partnerName; @@ -255,10 +259,11 @@ public sealed class SecureTradeUiController : IRetainedPanelController 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(); + // Retail's exact ID_SecureTrade_TotalItemsLabel — probe-verified + // token-free (gate round 3), composed through the same + // ResolveTemplate the confirmation dialogs use. + string line = _bindings.FormatTotalItems?.Invoke(count) + ?? count.ToString(); text.LinesProvider = () => [new UiText.Line(line, Vector4.One)]; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 24ac66f2..f40235b3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3524,7 +3524,11 @@ public sealed class RetailUiRuntime : IDisposable return; } - var bus = _bindings.Options.CommandBus(); + // Gate round 3 root cause: CommandBus is a Func for a REASON — the + // live route attaches at session start, AFTER this mount. Capturing + // the surface once here published every trade command into the + // pre-session null route, silently (the exact reason the social + // mount resolves the Func inside each lambda). Resolve per call. Layout.SecureTradeUiController? controller = Layout.SecureTradeUiController.Bind( layout, @@ -3532,24 +3536,43 @@ public sealed class RetailUiRuntime : IDisposable Trade: tradeView, Objects: _bindings.Inventory.Objects, ResolveIcon: _bindings.Inventory.ResolveIcon, - OpenTrade: partner => bus.Publish( + OpenTrade: partner => _bindings.Options.CommandBus().Publish( new OpenTradeNegotiationsRuntimeCmd(partner)), - CloseTrade: () => bus.Publish( + CloseTrade: () => _bindings.Options.CommandBus().Publish( new CloseTradeNegotiationsRuntimeCmd()), - AddToTrade: item => bus.Publish( + AddToTrade: item => _bindings.Options.CommandBus().Publish( new AddToTradeRuntimeCmd(item)), AcceptTrade: (selfAccepted, partnerAccepted, partner) => - bus.Publish(new AcceptTradeRuntimeCmd( + _bindings.Options.CommandBus().Publish(new AcceptTradeRuntimeCmd( partner, selfAccepted, partnerAccepted)), - DeclineTrade: () => bus.Publish(new DeclineTradeRuntimeCmd()), - ResetTrade: () => bus.Publish(new ResetTradeRuntimeCmd()), + DeclineTrade: () => _bindings.Options.CommandBus().Publish( + new DeclineTradeRuntimeCmd()), + ResetTrade: () => _bindings.Options.CommandBus().Publish( + new ResetTradeRuntimeCmd()), SetWindowVisible: visible => { if (visible) Host.ShowWindow(WindowNames.SecureTrade); else Host.HideWindow(WindowNames.SecureTrade); }, SelfEmptySlotSprite: selfEmptySlotSprite, - PartnerEmptySlotSprite: partnerEmptySlotSprite)); + PartnerEmptySlotSprite: partnerEmptySlotSprite, + // ID_SecureTrade_TotalItemsLabel (probe-verified + // token-free; the ITEMS variable) — retires AD-95. + FormatTotalItems: count => + { + lock (_bindings.Assets.DatLock) + { + var strings = new DatStringResolver(_bindings.Assets.Dats); + return strings.ResolveTemplate( + 0x23000001u, + "ID_SecureTrade_TotalItemsLabel", + new Dictionary + { + [DatStringResolver.ComputeHash("ITEMS")] = + count.ToString(), + }) ?? count.ToString(); + } + })); if (controller is null) { Console.WriteLine("[M4] secure trade: required authored grids are missing."); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index af25bf2d..c0cf97fc 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -610,6 +610,12 @@ public sealed class LiveSessionCommandRouterTests RemoveFriend: _ => { }, ClearFriends: () => { }, RequestLegacyFriends: () => { }, + OpenTradeNegotiations: _ => { }, + CloseTradeNegotiations: () => { }, + AddToTrade: _ => { }, + AcceptTrade: (_, _, _) => { }, + DeclineTrade: () => { }, + ResetTrade: () => { }, ModifyCharacterSquelch: (_, _, _, _) => { }, ModifyAccountSquelch: (_, _) => { }, ModifyGlobalSquelch: (_, _) => { }, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index a1adc020..2276902b 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1540,15 +1540,25 @@ public sealed class ItemInteractionControllerTests var payload = new ItemDragPayload( item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot()); + var tradeRequests = new List<(uint Partner, uint Item)>(); + h.Controller.SecureTradeRequested += (partner, dragged) => + tradeRequests.Add((partner, dragged)); + bool result = h.Controller.PlaceIn3D(payload, targetPlayer); Assert.Equal(sendsGive, result); if (sendsGive) + { Assert.Equal(new[] { (targetPlayer, item, 1u) }, h.Gives); + Assert.Empty(tradeRequests); + } else { + // Trade feature (2026-08-14): the option-on drag now raises the + // SecureTradeRequested seam (retail's AttemptToTradeItem + // @ 0x0056DF80) instead of the pre-feature stub toast. Assert.Empty(h.Gives); - Assert.Contains(h.Toasts, text => text.Contains("Secure trade", StringComparison.Ordinal)); + Assert.Equal([(targetPlayer, item)], tradeRequests); } Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId); } diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs index 992ae169..6f68b2eb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs @@ -103,6 +103,44 @@ public sealed class PowerbarLayoutProbeTests Console.WriteLine($"[pbprobe] scanned {scanned} layouts"); } + /// 2026-08-14 trade gate round 3: dump + /// ID_SecureTrade_TotalItemsLabel's fragments + variable hashes so the + /// count text can use ResolveTemplate if token-free (AD-95). + [Fact] + public void ProbeTotalItemsTemplate() + { + 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); + + uint hash = DatStringResolver.ComputeHash("ID_SecureTrade_TotalItemsLabel"); + var table = dats.Get(0x23000001u); + Console.WriteLine($"[pbprobe] key hash=0x{hash:X8} tableFound={table is not null}"); + if (table is not null && table.Strings.TryGetValue(hash, out var entry)) + { + for (int i = 0; i < entry.Strings.Count; i++) + Console.WriteLine($"[pbprobe] fragment[{i}]='{entry.Strings[i].Value}'"); + for (int i = 0; i < entry.Variables.Count; i++) + Console.WriteLine($"[pbprobe] variable[{i}]=0x{entry.Variables[i]:X8}"); + foreach (string candidate in new[] + { "COUNT", "NUM", "NUMBER", "ITEMS", "TOTAL", "AMOUNT", "N" }) + { + Console.WriteLine( + $"[pbprobe] hash('{candidate}')=0x{DatStringResolver.ComputeHash(candidate):X8}"); + } + } + else + { + Console.WriteLine("[pbprobe] entry NOT FOUND in 0x23000001"); + } + } + private static ElementInfo? FindById(ElementInfo element, uint id) { if (element.Id == id) return element;