Retail UIElement_UIItem::UpdateTooltip @0x004E1CB0 caches the item's NAME_APPROPRIATE display name (stack-count-prefixed "%d %s" when StackSize > 1) as m_TTText every UIItem_Update refresh; the generic UIElementManager::CheckTooltip dwell timer is what actually shows it on hover — no special-cased trigger of its own. UiItemSlot cells are built programmatically (never through LayoutImporter.Build), so #409's original round left this deferred: the class carried neither the popup locator (P0x47/P0x48) nor a name source. A live-DAT sweep of the shared UIItem cell-template catalog (ItemListCellTemplate.CatalogLayoutId, 0x21000037) found all 47 UIItem-type (class 0x10000032) prototypes — inventory's cell, every toolbar slot, every paperdoll/armor slot skin — resolve the IDENTICAL popup locator (P0x47=0x10000395/P0x48=0x21000041) through catalog inheritance, with no literal text authored on any of them. UiItemSlot now hardcodes that pair and exposes GetTooltipText() via a new TooltipTextResolve delegate, wired at every physical-item construction site: InventoryController (main-pack cell + grid cells), ExternalContainerController, PaperdollController (closes the separate gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF gap too — same cell class, same fix), VendorUiController (shop/buying/selling lists), SecureTradeUiController, ToolbarController. Text is the new ClientObject.GetTooltipDisplayName(): GetAppropriateName() prefixed with the stack count via "{count} {name}" when StackSize > 1, matching UpdateTooltip's exact NAME_APPROPRIATE + "%d %s" sprintf. UiCatalogSlot (spell/component catalog cells, a different UiItemSlot subclass) is unaffected — it already overrides GetTooltipText() with its own Label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
339 lines
14 KiB
C#
339 lines
14 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds the imported retail <c>gmSecureTradeUI</c> layout
|
|
/// (LayoutDesc <c>0x2100000D</c>, root <c>0x1000007A</c>) to
|
|
/// <see cref="RuntimeTradeState"/>'s view. No panel geometry is synthesized:
|
|
/// every control is the authored element.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail references (lane A, docs/research/2026-08-14-trade-laneA-ui.md):
|
|
/// <c>gmSecureTradeUI::PostInit @ 0x004CA160</c> binds exactly these ids;
|
|
/// <c>ListenToElementMessage @ 0x004CAE80</c> reacts to the Trade button
|
|
/// (0x10000086), Clear All (0x1000008A), and the close X (0x1000008B);
|
|
/// <c>RecvNotice_RegisterTrade @ 0x004CA5C0</c> 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):
|
|
/// <c>ItemHolder::DetermineUseResult @ 0x00588460</c> result 5 (Use on a
|
|
/// player) and <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>'s
|
|
/// DragItemOnPlayerOpensSecureTrade branch → both surface here through
|
|
/// <see cref="RequestSecureTrade"/> (the ItemInteractionController event),
|
|
/// mirroring <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
|
|
/// @ 0x0056DEE0</c> / <c>AttemptToTradeItem @ 0x0056DF80</c> — the latter's
|
|
/// "queue the dragged item until the window registers" is the pending-stage
|
|
/// latch consumed in <see cref="Tick"/>.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>The authored partner-status accept cue (probe: element
|
|
/// 0x1000007F states '', 'Highlight', 'Ghosted').</summary>
|
|
private const string AcceptedState = "Highlight";
|
|
|
|
/// <summary>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.</summary>
|
|
private const uint TradeOverlaySpriteId = 0x06001DAEu;
|
|
|
|
public sealed record Bindings(
|
|
IRuntimeTradeView Trade,
|
|
ClientObjectTable Objects,
|
|
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
|
Action<uint> OpenTrade,
|
|
Action CloseTrade,
|
|
Action<uint> AddToTrade,
|
|
Action<bool /*selfAccepted*/, bool /*partnerAccepted*/, uint /*partner*/> AcceptTrade,
|
|
Action DeclineTrade,
|
|
Action ResetTrade,
|
|
Action<bool> 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<int, string>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// (<c>AttemptToTradeItem</c>'s open-trade branch); otherwise open
|
|
/// negotiations and latch the item until RegisterTrade arrives.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Applies the latest owner snapshot (revision-gated).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Drops on the SELF grid stage the dragged inventory item
|
|
/// (retail's AcceptDragObject → AddToTrade path).</summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|