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"; /// The UIItem prototype's authored trade-state overlay sprite /// (catalog 0x21000037 element 0x10000438 — the green frame + corner /// trade icon; bound @ 0x004E18FC, shown when tradeState != 0 /// @ 0x004E2420). Retail sets tradeState=1 on YOUR staged items /// (gmSecureTradeUI::AddItem @ 0x004CA801), so the self grid marks. private const uint TradeOverlaySpriteId = 0x06001DAEu; public sealed record Bindings( IRuntimeTradeView Trade, ClientObjectTable Objects, Func ResolveIcon, Action OpenTrade, Action CloseTrade, Action AddToTrade, Action AcceptTrade, Action DeclineTrade, Action ResetTrade, Action SetWindowVisible, // The authored empty-slot background for each grid, resolved via // ItemListCellTemplate from the lists' own 0x1000000E cell-template // attribute (0x1000033A) — same recipe as the vendor strips. uint SelfEmptySlotSprite = 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; 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)); // Gate fix (2026-08-14 round 1: "broken trade window"): the grids // rendered their raw authored strip art with no cell layout at all — // the same single-row 32px config + authored empty-slot fill the // vendor/external-container strips use. ConfigureGrid(_selfList, bindings.SelfEmptySlotSprite); ConfigureGrid(_partnerList, bindings.PartnerEmptySlotSprite); _bindings.SetWindowVisible(false); } private static void ConfigureGrid(UiItemList? list, uint emptySlotSprite) { if (list is null) return; list.Columns = 1; list.SingleRow = true; list.HorizontalScroll = true; list.CellWidth = 32f; list.CellHeight = 32f; list.FillVisibleEmptySlots = true; if (emptySlotSprite != 0u) list.CellEmptySprite = emptySlotSprite; list.EmptySlotFactory = () => new UiItemSlot { SpriteResolve = list.SpriteResolve, AllowDragSource = 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; // 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)]; } 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, // Your staged items carry retail's trading marker. ShowTradeOverlay = side == RuntimeTradeSide.Self, TradeOverlaySprite = TradeOverlaySpriteId, TooltipTextResolve = g => _bindings.Objects.Get(g)?.GetTooltipDisplayName(), }; 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); } } }