diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs
index d7bf4460..db66e5ed 100644
--- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs
+++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs
@@ -363,6 +363,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
SpriteResolve = owner.SpriteResolve,
SlotIndex = owner.GetNumUIItems(),
SourceKind = source,
+ TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
return cell;
diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs
index f39b8e83..320a6eec 100644
--- a/src/AcDream.App/UI/Layout/InventoryController.cs
+++ b/src/AcDream.App/UI/Layout/InventoryController.cs
@@ -444,7 +444,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
_topContainer.Flush();
- var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
+ var main = new UiItemSlot
+ {
+ SpriteResolve = _topContainer.SpriteResolve,
+ TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
+ };
main.SetItem(
p,
_iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u),
@@ -486,7 +490,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint dragTex = item is null ? 0u
: _dragIconIds?.Invoke(
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.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs
index 9d84f10b..4d925ed9 100644
--- a/src/AcDream.App/UI/Layout/PaperdollController.cs
+++ b/src/AcDream.App/UI/Layout/PaperdollController.cs
@@ -88,6 +88,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
list.ExamineItemRequested = ExamineItem;
list.Cell.SourceKind = ItemDragSource.Equipment;
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
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite)
? authoredSprite
diff --git a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
index b03fc02b..091a5574 100644
--- a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
+++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
@@ -295,6 +295,7 @@ public sealed class SecureTradeUiController : IRetainedPanelController
// 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);
diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
index 9d880d84..e0b0c010 100644
--- a/src/AcDream.App/UI/Layout/ToolbarController.cs
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -145,6 +145,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
+ list.Cell.TooltipTextResolve = g => _repo.Get(g)?.GetTooltipDisplayName();
}
}
diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs
index b1edcc8b..b1ddf49e 100644
--- a/src/AcDream.App/UI/Layout/VendorUiController.cs
+++ b/src/AcDream.App/UI/Layout/VendorUiController.cs
@@ -1102,6 +1102,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// comment for why this must be gated at the source,
// not left to every destination handler to reject.
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.Selected = item.ItemGuid == selectedGuid;
@@ -2205,6 +2209,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
AllowDragSource = false,
+ TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
cell.SetItem(shopItem.ItemGuid, icon);
cell.Selected = shopItem.ItemGuid == selectedGuid;
@@ -2243,6 +2248,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
AllowDragSource = false,
+ TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
cell.SetItem(item.ObjectId, icon);
cell.Selected = item.ObjectId == selectedGuid;
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index 614ec3b6..8a06c830 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -12,13 +12,63 @@ namespace AcDream.App.UI;
///
public class UiItemSlot : UiElement
{
- public UiItemSlot() { ClickThrough = false; }
+ ///
+ /// Retail's shared UIItem cell-template catalog (ItemListCellTemplate.
+ /// CatalogLayoutId, LayoutDesc 0x21000037) 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 0x1000033A, the toolbar's per-slot prototypes
+ /// 0x1000043B.., the container cell 0x1000033F, and every
+ /// paperdoll/armor slot skin alike) resolves P0x47=0x10000395 /
+ /// P0x48=0x21000041 through catalog inheritance, matching one of
+ /// the four popup skins already
+ /// mounts for every other tooltip-bearing element
+ /// (Layout.TooltipLiveDatTests.PopupSkinRootIds). Since
+ /// cells are built programmatically (never through
+ /// LayoutImporter.Build), this port hardcodes the uniform pair here
+ /// rather than re-deriving it per instance — the same "exhaustive scan,
+ /// then hardcode" shape as RetailCursorCatalog's five window-control
+ /// cursor DIDs and ItemListCellTemplate.CatalogLayoutId itself.
+ ///
+ private const uint ItemTooltipRootElementId = 0x10000395u;
+ private const uint ItemTooltipLayoutDid = 0x21000041u;
+
+ public UiItemSlot()
+ {
+ ClickThrough = false;
+ AuthoredTooltipRootElementId = ItemTooltipRootElementId;
+ AuthoredTooltipLayoutDid = ItemTooltipLayoutDid;
+ }
public override bool ConsumesDatChildren => true;
/// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.
public uint ItemId { get; private set; }
+ ///
+ /// Resolves to its retail tooltip text (a
+ ///
+ /// call bound by the owning controller — every construction site already
+ /// has a ClientObjectTable reference in scope, matching how
+ /// is wired). Null/empty result shows no
+ /// tooltip, matching retail's UIItem_Update early-out
+ /// (weenObj == 0 -> UIElement::ClearTooltip) for an
+ /// empty or not-yet-materialized cell.
+ ///
+ public Func? TooltipTextResolve { get; set; }
+
+ ///
+ /// Port of UIElement_UIItem::UpdateTooltip @0x004E1CB0: 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 and
+ /// — rather than cached at
+ /// time, since nothing observes a stale value between
+ /// item-state changes and the next hover dwell.
+ ///
+ public override string? GetTooltipText()
+ => ItemId != 0 ? TooltipTextResolve?.Invoke(ItemId) : null;
+
/// Pre-composited icon GL texture for the bound item (0 = none).
public uint IconTexture { get; private set; }
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index 5f4611d0..42a99890 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -347,6 +347,22 @@ public sealed class ClientObject
if (string.IsNullOrEmpty(Name)) return Name;
return Name[^1] == 's' ? Name + "es" : Name + "s";
}
+
+ ///
+ /// Ports UIElement_UIItem::UpdateTooltip @0x004E1CB0 — the item-cell
+ /// hover-tooltip text every physical item/container/shortcut cell shows.
+ /// Retail resolves the name with NAME_APPROPRIATE (the same call
+ /// already ports) and, only when the stack
+ /// holds more than one (_stackSize_1 > 1 @0x004e1d12), prefixes the
+ /// count via PStringBase<unsigned short>::sprintf(&__return,
+ /// u"%d %s") — count first, one space, then the (already
+ /// singular-or-plural) name.
+ ///
+ public string GetTooltipDisplayName()
+ {
+ string name = GetAppropriateName();
+ return StackSize > 1 ? $"{StackSize} {name}" : name;
+ }
}
///