feat(ui): inventory/shortcut/paperdoll item-name tooltips — UIElement_UIItem::UpdateTooltip port

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>
This commit is contained in:
Erik 2026-08-16 23:02:10 +02:00
parent 5f9ca18155
commit e29c61a3a4
8 changed files with 87 additions and 3 deletions

View file

@ -363,6 +363,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
SpriteResolve = owner.SpriteResolve, SpriteResolve = owner.SpriteResolve,
SlotIndex = owner.GetNumUIItems(), SlotIndex = owner.GetNumUIItems(),
SourceKind = source, SourceKind = source,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(guid, icon, dragIconTexture: dragIcon); cell.SetItem(guid, icon, dragIconTexture: dragIcon);
return cell; return cell;

View file

@ -444,7 +444,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate) const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
_topContainer.Flush(); _topContainer.Flush();
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; var main = new UiItemSlot
{
SpriteResolve = _topContainer.SpriteResolve,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
main.SetItem( main.SetItem(
p, p,
_iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u), _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u),
@ -486,7 +490,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint dragTex = item is null ? 0u uint dragTex = item is null ? 0u
: _dragIconIds?.Invoke( : _dragIconIds?.Invoke(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u; item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; var cell = new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
cell.SetItem(guid, tex, dragIconTexture: dragTex); cell.SetItem(guid, tex, dragIconTexture: dragTex);
cell.SetWaitingState(waiting); cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)

View file

@ -88,6 +88,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
list.ExamineItemRequested = ExamineItem; list.ExamineItemRequested = ExamineItem;
list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot
list.Cell.TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName();
list.Cell.EmptySprite = emptySlotSprites is not null list.Cell.EmptySprite = emptySlotSprites is not null
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite) && emptySlotSprites.TryGetValue(element, out uint authoredSprite)
? authoredSprite ? authoredSprite

View file

@ -295,6 +295,7 @@ public sealed class SecureTradeUiController : IRetainedPanelController
// Your staged items carry retail's trading marker. // Your staged items carry retail's trading marker.
ShowTradeOverlay = side == RuntimeTradeSide.Self, ShowTradeOverlay = side == RuntimeTradeSide.Self,
TradeOverlaySprite = TradeOverlaySpriteId, TradeOverlaySprite = TradeOverlaySpriteId,
TooltipTextResolve = g => _bindings.Objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(guid, icon); cell.SetItem(guid, icon);
list.AddItem(cell); list.AddItem(cell);

View file

@ -145,6 +145,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
list.Cell.SlotIndex = i; list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar; list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
list.Cell.TooltipTextResolve = g => _repo.Get(g)?.GetTooltipDisplayName();
} }
} }

View file

@ -1102,6 +1102,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// comment for why this must be gated at the source, // comment for why this must be gated at the source,
// not left to every destination handler to reject. // not left to every destination handler to reject.
AllowDragSource = false, AllowDragSource = false,
// Shop items are materialized into ClientObjectTable
// (VendorShopItemMaterializer), so the same resolver
// every other physical cell uses works here too.
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(item.ItemGuid, icon); cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == selectedGuid; cell.Selected = item.ItemGuid == selectedGuid;
@ -2205,6 +2209,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SpriteResolve = list.SpriteResolve, SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(), SlotIndex = list.GetNumUIItems(),
AllowDragSource = false, AllowDragSource = false,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(shopItem.ItemGuid, icon); cell.SetItem(shopItem.ItemGuid, icon);
cell.Selected = shopItem.ItemGuid == selectedGuid; cell.Selected = shopItem.ItemGuid == selectedGuid;
@ -2243,6 +2248,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SpriteResolve = list.SpriteResolve, SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(), SlotIndex = list.GetNumUIItems(),
AllowDragSource = false, AllowDragSource = false,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
}; };
cell.SetItem(item.ObjectId, icon); cell.SetItem(item.ObjectId, icon);
cell.Selected = item.ObjectId == selectedGuid; cell.Selected = item.ObjectId == selectedGuid;

View file

@ -12,13 +12,63 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public class UiItemSlot : UiElement public class UiItemSlot : UiElement
{ {
public UiItemSlot() { ClickThrough = false; } /// <summary>
/// Retail's shared UIItem cell-template catalog (<c>ItemListCellTemplate.
/// CatalogLayoutId</c>, LayoutDesc <c>0x21000037</c>) authors the SAME
/// tooltip popup locator on every one of its 49 standalone prototypes —
/// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's
/// 32x32 cell <c>0x1000033A</c>, the toolbar's per-slot prototypes
/// <c>0x1000043B</c>.., the container cell <c>0x1000033F</c>, and every
/// paperdoll/armor slot skin alike) resolves <c>P0x47=0x10000395</c> /
/// <c>P0x48=0x21000041</c> through catalog inheritance, matching one of
/// the four popup skins <see cref="Layout.RetailTooltipPresenter"/> already
/// mounts for every other tooltip-bearing element
/// (<c>Layout.TooltipLiveDatTests.PopupSkinRootIds</c>). Since
/// <see cref="UiItemSlot"/> cells are built programmatically (never through
/// <c>LayoutImporter.Build</c>), this port hardcodes the uniform pair here
/// rather than re-deriving it per instance — the same "exhaustive scan,
/// then hardcode" shape as <c>RetailCursorCatalog</c>'s five window-control
/// cursor DIDs and <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
/// </summary>
private const uint ItemTooltipRootElementId = 0x10000395u;
private const uint ItemTooltipLayoutDid = 0x21000041u;
public UiItemSlot()
{
ClickThrough = false;
AuthoredTooltipRootElementId = ItemTooltipRootElementId;
AuthoredTooltipLayoutDid = ItemTooltipLayoutDid;
}
public override bool ConsumesDatChildren => true; public override bool ConsumesDatChildren => true;
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary> /// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
public uint ItemId { get; private set; } public uint ItemId { get; private set; }
/// <summary>
/// Resolves <see cref="ItemId"/> to its retail tooltip text (a
/// <see cref="AcDream.Core.Items.ClientObject.GetTooltipDisplayName"/>
/// call bound by the owning controller — every construction site already
/// has a <c>ClientObjectTable</c> reference in scope, matching how
/// <see cref="SpriteResolve"/> is wired). Null/empty result shows no
/// tooltip, matching retail's <c>UIItem_Update</c> early-out
/// (<c>weenObj == 0</c> -&gt; <c>UIElement::ClearTooltip</c>) for an
/// empty or not-yet-materialized cell.
/// </summary>
public Func<uint, string?>? TooltipTextResolve { get; set; }
/// <summary>
/// Port of <c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c>: called every
/// refresh (retail's own callers are heartbeat/state-change driven), but
/// computed lazily here — the same "runtime text on demand" shape already
/// established by <see cref="UiButton.GetTooltipText"/> and
/// <see cref="UiRadar.GetTooltipText"/> — rather than cached at
/// <see cref="SetItem"/> time, since nothing observes a stale value between
/// item-state changes and the next hover dwell.
/// </summary>
public override string? GetTooltipText()
=> ItemId != 0 ? TooltipTextResolve?.Invoke(ItemId) : null;
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary> /// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; } public uint IconTexture { get; private set; }

View file

@ -347,6 +347,22 @@ public sealed class ClientObject
if (string.IsNullOrEmpty(Name)) return Name; if (string.IsNullOrEmpty(Name)) return Name;
return Name[^1] == 's' ? Name + "es" : Name + "s"; return Name[^1] == 's' ? Name + "es" : Name + "s";
} }
/// <summary>
/// Ports <c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c> — the item-cell
/// hover-tooltip text every physical item/container/shortcut cell shows.
/// Retail resolves the name with <c>NAME_APPROPRIATE</c> (the same call
/// <see cref="GetAppropriateName"/> already ports) and, only when the stack
/// holds more than one (<c>_stackSize_1 &gt; 1 @0x004e1d12</c>), prefixes the
/// count via <c>PStringBase&lt;unsigned short&gt;::sprintf(&amp;__return,
/// u"%d %s")</c> — count first, one space, then the (already
/// singular-or-plural) name.
/// </summary>
public string GetTooltipDisplayName()
{
string name = GetAppropriateName();
return StackSize > 1 ? $"{StackSize} {name}" : name;
}
} }
/// <summary> /// <summary>