Merge campaign-409-tooltips: hover-feedback completion — item names, world-object tooltips, item cursor swap
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Item-name tooltips on every UiItemSlot surface (UIElement_UIItem::UpdateTooltip
@0x004E1CB0, stack-count prefix, the 47-prototype shared popup locator);
world-object hover tooltips via retail's SmartBox found-object pipeline
(RecvNotice_SmartBoxObjectFound @0x004E5AD0 — immediate, ShowTooltips-gated,
no stack prefix — live-verified: 'Silver Tusker' + DefaultFound cursor from
one pipeline); #411 CLOSED — retail's set_found_object fires unconditionally
over UI items, the user's memory was right, our target-mode gate was wrong.
TS-85 narrowed to the enumerated remainder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 23:04:06 +02:00
commit 63b0668fb9
17 changed files with 717 additions and 28 deletions

View file

@ -26,13 +26,33 @@ What does NOT go here:
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
**Status:** OPEN (filed 2026-08-16 during the #409 live-failure investigation, at the
lead's scope addition; user report: "the cursor should light up when I hover over an
item in inventory. it does not")
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
user's answer to the open question below ("the POINTER changes, like it already
does over world NPCs") is CONFIRMED by decomp, not merely a memory to trust:
`UIElement_SmartBoxWrapper::FindObject @0x004E5430` calls
`SmartBox::set_found_object(itemID)` UNCONDITIONALLY whenever the hovered UI
element casts to `UIElement_UIItem` — not gated on target mode, as the port plan
below (written before this finding) assumed it would need to be. Fixed with a
ONE-LINE widening of `CursorFeedbackController.Update(UiRoot)`'s existing
(too-narrow) item-hover special case; `ResolveGlobalKind` needed no changes,
since it already read the "found" flag unconditionally across every mode. See
docs/ISSUES.md #409's own "hover-feedback completion round" write-up (item 3)
for the full derivation and `CursorFeedbackControllerTests.
UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_In{OrdinaryPeaceMode,CombatMode}`
for the pin. The rollover-STATE half of this investigation (item 3 below,
`UiItemSlot` has no `HoverEnter`/`HoverLeave`) was NOT in scope for the pointer
question and remains unaddressed if the user separately wants the highlight —
file a fresh issue if so; this closure covers only the pointer-swap question the
lead's scope addition asked about.
**Severity:** LOW (cosmetic/affordance; no gameplay impact)
**Depends on:** nothing — the hover dispatch it needs is already correct (see #409's
live-failure round, which proved `UiRoot.UpdateHover` selects the right widget).
**Original investigation (below), kept for its still-valid layer 1/2/3 breakdown —
only the "never fires over an inventory item" conclusion for layer 2 was
incomplete; see the closure note above for the corrected reading.**
**Retail mechanism, derived from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.**
There are THREE separate hover-feedback layers, and the DAT decides which one applies:
@ -188,7 +208,7 @@ horizontal `HJustify` mapping while in this code, since it shares the
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11 and the LIVE-FAILURE round both landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the user's full connected gate is still owed.
**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11, the LIVE-FAILURE round, and the hover-feedback completion round (item-cell tooltips + world-object hover tooltip) all landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the hover-feedback completion round's three items are automated-gate-verified (unit + live-DAT) but the user's connected gate for THOSE items specifically is still owed — see that round's own "Live-verify all three" note.
**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix)
**2026-08-16 re-derivation + port.** Full re-derivation from
@ -353,16 +373,113 @@ the "Slots" button.
spin arrows — longer text, should WRAP rather than run off-screen; (7) the
Heritage/Profession/Skills/Town/Summary tab buttons.
*Expected NOT to show anything yet (deferred, register TS-85):* hovering an
inventory ITEM icon. Retail shows the item name there
(`UIElement_UIItem::UpdateTooltip @0x004E1CB0`); acdream shows nothing.
Confirm also: the box sits offset down-right of the cursor (retail's +32px on
both axes), never runs off the edge of the window even near a corner, and
disappears on its own after ~10 s if you hold still without moving away. No
click-to-dismiss is expected — only moving off the control, or a very long
hold, closes it.
**2026-08-16 hover-feedback completion round.** Closes the two items the
live-failure round explicitly deferred (item-cell tooltips, and the #411
pointer question), plus the world-object hover tooltip the user's gate notes
called out separately.
1. **Inventory/shortcut/paperdoll item-name tooltips — SHIPPED.**
`UIElement_UIItem::UpdateTooltip @0x004E1CB0` is called from
`UIItem_Update` (an item-DATA-CHANGE refresh, not a hover handler — the
trigger that actually SHOWS it is the generic `CheckTooltip` dwell timer,
same as any other tooltip-bearing element). Re-derived and closed the gap
the live-failure round left open ("acdream's `UiItemSlot` carries neither
the `P0x47` popup locator nor a name source"): 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
(`TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`).
`UiItemSlot` now hardcodes that pair and exposes `GetTooltipText()` via a
new per-instance `TooltipTextResolve` delegate, wired at every physical-
item construction site — `InventoryController` (main-pack cell + grid
cells), `ExternalContainerController`, `PaperdollController` (closes the
`gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row too — same cell
class, same fix), `VendorUiController` (shop/buying/selling lists),
`SecureTradeUiController`, `ToolbarController`. Text is
`ClientObject.GetTooltipDisplayName()` (new Core method): `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`.
2. **World-object hover tooltip (NPCs, players, signs, chests, portals) —
SHIPPED.** NOT the UI-element dwell-timer path — retail's mechanism is
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`,
fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620`
using the current mouse position regardless of input focus. It fires
IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the
`PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips`
— already modeled in `CharacterOptionTable`, default true), with text
`ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name
call as item tooltips, but WITHOUT the item-cell's separate stack-count
prefix (a real, decomp-confirmed asymmetry: a ground pile of arrows shows
"Arrows", not "20 Arrows"). Ported as `RetailTooltipPresenter.
UpdateWorldHoverTooltip`, driven by the SAME world-hover pick
`CursorFeedbackController`'s own found-cursor already uses
(`WorldSelectionQuery.PickAtCursor`, `includeSelf: true`) and the SAME
`ClientObjectTable`-backed name resolver `SocialAllegiancePageController`'s
`ResolveWorldObjectName` already established as this codebase's pattern.
Queried only when no UI element is hovered (this port's reading of
`FindObject`'s `m_pElementLastOver` check, narrowed from retail's literal
"raycast even under non-item UI chrome" — see the class's own doc note).
**Own player is included** (`includeSelf: true`, the same precedent the
cursor feedback wiring already set) — no decomp evidence was found either
confirming or excluding self from the found-object pipeline, so this
follows the established local precedent rather than guessing fresh; flag
if that reads wrong in the live gate. **The exact popup skin is an
inference, not a measured value** — an exhaustive live-DAT sweep found
`UIElement_SmartBoxWrapper` (class `0x10000030`) has NO authored
`ElementDesc` anywhere installed (unlike every other tooltip trigger, it
is evidently constructed directly by `gmGamePlayUI`'s own mode setup, not
from a walkable LayoutDesc — `TooltipLiveDatTests.
SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`), so its real
`P0x47`/`P0x48` cannot be read off the DAT the way the item catalog's can.
This port reuses the SAME pair every other game-code `SetTooltip` caller
in this family resolves to — the best-evidenced choice, called out in
register row TS-85 rather than silently assumed exact.
3. **#411 resolved: retail DOES swap the pointer over inventory items,
unconditionally — the earlier investigation's "never fires over an
inventory item" finding was INCOMPLETE, not wrong about what it checked.**
The original #411 scan (below) correctly found no PER-ELEMENT authored
cursor on item cells and correctly found `SmartBox::get_found_object_id()`
is written only by `UIElement_SmartBoxWrapper` — but it had not yet traced
`FindObject @0x004E5430` far enough: when the currently-hovered UI element
(`m_pElementLastOver`) casts to `UIElement_UIItem` (class `0x10000032`),
`FindObject` calls `SmartBox::set_found_object(itemID, 0xFFFFFFFF)`
directly and returns WITHOUT running the 3D raycast — UNCONDITIONALLY, not
gated on target mode. `ClientUISystem::UpdateCursorState @0x00564630`
computes its "found" flag ONCE at the top of the function
(`ebx = SmartBox::get_found_object_id() != 0`, `@0x00564642`) and every
later branch (default/melee-missile/magic/use/examine/use-target/busy)
reads that SAME flag — so hovering an occupied item cell shows the
cursor's "...Found" variant in EVERY mode, not only during an active
`UseTarget` selection. `CursorFeedbackController.Update(UiRoot)` already
had the item-hover special case wired (from an earlier round) but
incorrectly gated it to `TargetMode.UseTarget` only; that one-line gate is
now removed — `ResolveGlobalKind`'s existing found/not-found branching
needed no changes at all, since it already read the snapshot's
`HoverTargetGuid` unconditionally across every mode. Live-DAT-independent
(pure decomp + unit fixture), so no DAT sweep was needed for this part;
two new `CursorFeedbackControllerTests` pin the widened behavior in
ordinary peace mode and in combat mode.
**Live-verify all three on the connected client** (session-config launch,
graceful close per the usual rules): hover an inventory item — a name
tooltip should appear (with a count prefix for a stack) AND the mouse
pointer should swap to its "found" variant; hover an NPC/creature — a name
tooltip should appear immediately (no perceptible delay) if "Show Tooltips"
is on; hover a sign/chest/portal similarly.
---
**Original GF-16 filing (superseded by the re-derivation above; kept for

File diff suppressed because one or more lines are too long

View file

@ -842,6 +842,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Actions.Selection,
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
// #409 follow-on ("Item 2"): world-object hover tooltip.
// Reuses the SAME world-hover pick cursorFeedback's own
// worldTargetProvider already calls (UIElement_SmartBoxWrapper::
// FindObject's 3D-raycast fallback — RetailWorldPicker's exact
// port) and the SAME ClientObjectTable name resolver
// ResolveWorldObjectName already uses elsewhere in this file.
WorldTooltip: new WorldTooltipRuntimeBindings(
HoverGuidAtCursor: () => late.Selection.PickAtCursor(includeSelf: true),
ResolveName: guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(),
Enabled: () => d.Character.Options.GetOptionBit(
CharacterOptionId.ShowTooltips)),
Confirmations: new ConfirmationRuntimeBindings(
(type, context, accepted) =>
late.Session.CurrentSession?.SendConfirmationResponse(

View file

@ -109,17 +109,32 @@ public sealed class CursorFeedbackController
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off
// the SmartBox found object — the WORLD entity under the cursor. A UI
// window occludes the world (no found object → pending). The one
// UI-side source retail-style cells contribute is an occupied item
// slot's own item.
// Retail UpdateCursorState (0x00564630) keys EVERY mode's cursor off
// the SAME SmartBox found-object flag, computed once at the top of
// the function (ebx = SmartBox::get_found_object_id() != 0,
// @0x00564642) and read verbatim by every later branch — target mode
// and combat mode only pick WHICH cursor variant (Default vs.
// DefaultFound, Use vs. UseFound, ...) to show for that SAME found
// state, never whether it is set.
//
// #411 correction (2026-08-16): the found object itself is not
// world-only. UIElement_SmartBoxWrapper::FindObject @0x004E5430 runs
// every frame regardless of input focus (Global_Loop @0x004E5620)
// and, when the currently-hovered UI element (m_pElementLastOver)
// casts to UIElement_UIItem (0x10000032), calls
// SmartBox::set_found_object(itemID) directly — UNCONDITIONALLY, not
// gated on target mode — and returns WITHOUT running the 3D raycast.
// So hovering an occupied item cell sets the SAME found flag in
// EVERY mode (peace, melee/missile, magic, busy, examine, use,
// use-target), which is why retail's cursor visibly changes there —
// the earlier reading here (item slots only contribute in
// UseTarget) covered only the Valid/Invalid sub-branch, not the
// found flag driving the Default/Combat/Use/Examine/Busy Found
// variants too.
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
uint hoverTarget = hover is null
? _worldTargetProvider?.Invoke() ?? 0u
: targetMode == RetailCursorTargetMode.UseTarget
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
: 0u;
: FindHoveredItemSlot(hover)?.ItemId ?? 0u;
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
&& hoverTarget != 0
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)

View file

@ -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;

View file

@ -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)

View file

@ -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

View file

@ -173,20 +173,36 @@ public sealed class RetailTooltipPresenter : IDisposable
if (layoutDid == 0u)
return;
if (TryBuildAndMountPopup(widget.AuthoredTooltipRootElementId, layoutDid, tooltipText!))
_owner = widget;
}
/// <summary>
/// Shared popup-build/mount body for BOTH tooltip triggers this class
/// owns: the UI-element hover-dwell path above (<see cref="OnTooltipShow"/>,
/// per-widget authored <c>P0x47</c>/<c>P0x48</c>) and the world-object
/// hover path below (<see cref="UpdateWorldHoverTooltip"/>, the fixed
/// popup-skin pair every game-code <c>SetTooltip</c> caller in this
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
/// <c>OnTooltipShow</c> body — same F4/F5/F8 fixes, same failure
/// handling.
/// </summary>
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
{
ImportedLayout? layout;
try
{
layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId);
layout = _createLayout(layoutDid, rootElementId);
}
catch (Exception error)
{
Console.WriteLine(
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
return;
+ $"root=0x{rootElementId:X8} failed to build: {error.Message}");
return false;
}
if (layout is null)
return;
return false;
UiElement root = layout.Root;
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
@ -202,7 +218,7 @@ public sealed class RetailTooltipPresenter : IDisposable
// show an empty, unsized 30x30 bevel artifact instead of retail's
// silent no-op.
if (textChild is not UiText text)
return;
return false;
// F4: null the per-frame anchor recompute on BOTH the popup root and
// its text child before resizing, the same shape the sibling
@ -216,7 +232,7 @@ public sealed class RetailTooltipPresenter : IDisposable
text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, tooltipText!);
ApplyTooltipText(root, text, tooltipText);
SetClickThroughRecursive(root);
PositionAtMouse(root);
@ -224,7 +240,7 @@ public sealed class RetailTooltipPresenter : IDisposable
_host.AddChild(root);
_host.BringToFront(root);
_popupRoot = root;
_owner = widget;
return true;
}
private void OnTooltipHide(UiElement widget)
@ -240,6 +256,122 @@ public sealed class RetailTooltipPresenter : IDisposable
_host.RemoveChild(_popupRoot);
_popupRoot = null;
_owner = null;
_worldTooltipShowing = false;
}
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
//
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00). Unlike the
// dwell-timer UI-element path above, this trigger is EDGE-fired: retail
// calls SetTooltip + StartTooltipAtMouse IMMEDIATELY when SmartBox's
// found-object id CHANGES (@0x004E5D74/@0x004E5DFB) — no dwell wait —
// gated per-edge by PlayerModule::ShowTooltips (@0x004E5D21,
// CharacterOptionId.ShowTooltips in this port's CharacterOptionTable).
// The text is ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)
// (@0x004E5D3B) — the SAME call UIElement_UIItem::UpdateTooltip uses,
// but WITHOUT that item-cell's separate stack-count "%d %s" prefix
// (RecvNotice_SmartBoxObjectFound's own text-building block has no
// count logic at all — a real, decomp-confirmed asymmetry versus
// UiItemSlot's GetTooltipDisplayName).
//
// The found-object id itself comes from UIElement_SmartBoxWrapper::
// FindObject @0x004E5430, called every frame from Global_Loop
// @0x004E5620 using the CURRENT mouse position regardless of what has
// input focus. FindObject special-cases m_pElementLastOver casting to
// UIElement_UIItem (SmartBox::set_found_object(itemID) — item cells
// own their own answer, ported as UiItemSlot.GetTooltipText, Item 1);
// otherwise it runs the ordinary 3D raycast even under non-item UI
// chrome. This port narrows that second branch to "no UI element
// hovered at all" (see WorldHoverGuidProvider's own doc) rather than
// reproducing the raycast-under-windows edge case.
//
// UIElement_SmartBoxWrapper is registered class 0x10000030
// (Register @0x0047A47E) but an exhaustive live-DAT sweep
// (TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_
// AnywhereInstalled) found ZERO elements of that type anywhere
// installed — unlike the UIItem catalog's 49 standalone template
// prototypes, the 3D-viewport wrapper is evidently constructed
// directly by gmGamePlayUI's own mode setup rather than from a
// walkable authored ElementDesc, so its own P0x47/P0x48 cannot be
// read from the DAT. This port therefore REUSES the item catalog's
// confirmed uniform popup-locator pair (WorldPopupRootElementId/
// WorldPopupLayoutDid below) — the SAME "generic runtime-text" skin
// every other game-code SetTooltip caller in this family draws from —
// as the best-evidenced inference for the unrecoverable constant.
/// <summary>Same popup skin every UIItem prototype resolves to
/// (<see cref="UiItemSlot"/>'s own <c>ItemTooltipRootElementId</c>) —
/// see this section's own doc note on why the exact value cannot be
/// read off an authored <c>UIElement_SmartBoxWrapper</c> ElementDesc.</summary>
private const uint WorldPopupRootElementId = 0x10000395u;
private const uint WorldPopupLayoutDid = 0x21000041u;
private uint _worldHoverGuid;
private bool _worldTooltipShowing;
/// <summary>
/// The world-hover pick (retail's <c>SmartBox::find_object</c> via
/// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'s fallback
/// branch) — a per-frame "what's under the cursor right now" query,
/// distinct from click-driven <c>SelectionState</c>. Queried only when
/// <see cref="UiRoot.Pick"/> finds no UI element under the cursor
/// (mirrors <c>FindObject</c>'s <c>m_pElementLastOver</c> check, narrowed
/// per this section's own doc note). Null/unset disables the whole
/// world-hover path (no world tooltip, matching a client build that
/// never wires it).
/// </summary>
public Func<uint?>? WorldHoverGuidProvider { get; set; }
/// <summary>
/// <c>ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)</c> —
/// resolve the found guid's display name. Returning null/empty shows no
/// tooltip for that guid (matching retail's own non-empty-string guard
/// at <c>@0x004E5D48</c>).
/// </summary>
public Func<uint, string?>? WorldHoverNameResolver { get; set; }
/// <summary>
/// <c>PlayerModule::ShowTooltips</c> (<c>CharacterOptionId.ShowTooltips</c>) —
/// read once per found-object edge, exactly where retail reads it
/// (<c>@0x004E5D21</c>). A mid-hover option toggle takes effect at the
/// NEXT found-object change, matching retail's own edge-only
/// re-evaluation rather than a live per-frame re-check.
/// </summary>
public Func<bool>? WorldTooltipsEnabled { get; set; }
private void UpdateWorldHoverTooltip()
{
if (WorldHoverGuidProvider is null)
return;
uint found = _host.Pick(_host.MouseX, _host.MouseY) is null
? WorldHoverGuidProvider() ?? 0u
: 0u;
if (found == _worldHoverGuid)
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires
_worldHoverGuid = found;
if (found == 0u)
{
if (_worldTooltipShowing)
RemovePopup();
return;
}
if (WorldTooltipsEnabled?.Invoke() != true)
return;
string? text = WorldHoverNameResolver?.Invoke(found);
if (string.IsNullOrEmpty(text))
return;
// A UI-element popup cannot be showing here: UiRoot's own hover
// (queried above) is null whenever this branch runs, so its dwell
// timer never arms and OnTooltipShow never fires concurrently.
if (TryBuildAndMountPopup(WorldPopupRootElementId, WorldPopupLayoutDid, text))
_worldTooltipShowing = true;
}
/// <summary>Force-hides whatever tooltip is currently showing, if any.
@ -371,6 +503,8 @@ public sealed class RetailTooltipPresenter : IDisposable
{
if (_popupRoot is not null)
_host.BringToFront(_popupRoot);
UpdateWorldHoverTooltip();
}
public void Dispose()

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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;

View file

@ -333,6 +333,17 @@ public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback,
RetailCursorManager Manager);
/// <summary>
/// #409 follow-on (docs/ISSUES.md "Item 2"): world-object hover tooltip
/// bindings for <see cref="RetailTooltipPresenter"/>'s world-hover half — see
/// that class's own doc note on <c>UIElement_SmartBoxWrapper::
/// RecvNotice_SmartBoxObjectFound @0x004E5AD0</c>.
/// </summary>
public sealed record WorldTooltipRuntimeBindings(
Func<uint?> HoverGuidAtCursor,
Func<uint, string?> ResolveName,
Func<bool> Enabled);
public sealed record ConfirmationRuntimeBindings(
Action<uint, uint, bool> SendResponse);
@ -435,6 +446,7 @@ public sealed record RetailUiRuntimeBindings(
ExternalContainerRuntimeBindings ExternalContainer,
VendorRuntimeBindings Vendor,
RetailUiCursorBindings Cursor,
WorldTooltipRuntimeBindings WorldTooltip,
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options,
@ -3366,7 +3378,15 @@ public sealed class RetailUiRuntime : IDisposable
}
}
TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout);
TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout)
{
// #409 follow-on ("Item 2"): the world-object hover half —
// see RetailTooltipPresenter's own doc note on
// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound.
WorldHoverGuidProvider = () => _bindings.WorldTooltip.HoverGuidAtCursor(),
WorldHoverNameResolver = _bindings.WorldTooltip.ResolveName,
WorldTooltipsEnabled = () => _bindings.WorldTooltip.Enabled(),
};
if (_bindings.Chat.Store is { } store)
{

View file

@ -12,13 +12,63 @@ namespace AcDream.App.UI;
/// </summary>
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;
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
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>
public uint IconTexture { get; private set; }

View file

@ -347,6 +347,22 @@ public sealed class ClientObject
if (string.IsNullOrEmpty(Name)) return Name;
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>

View file

@ -308,6 +308,51 @@ public sealed class CursorFeedbackControllerTests
Assert.False(feedback.Cursor.IsValid);
}
/// <summary>
/// #411 fix (2026-08-16): UIElement_SmartBoxWrapper::FindObject
/// @0x004E5430 calls SmartBox::set_found_object(itemID) whenever the
/// hovered UI element casts to UIElement_UIItem, UNCONDITIONALLY — not
/// only in TARGET_MODE_USE_TARGET. ClientUISystem::UpdateCursorState
/// @0x00564630 then reads that SAME found flag from every mode branch
/// (Default/Combat/Use/Examine/Busy), so hovering an occupied item cell
/// in ordinary peace mode (no active interaction mode at all) must show
/// the Found cursor variant. This is the earlier-STOPped "does retail
/// swap the pointer over inventory items" question, resolved: yes,
/// unconditionally.
/// </summary>
[Fact]
public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InOrdinaryPeaceMode()
{
var root = new UiRoot { Width = 800, Height = 600 };
var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 };
slot.SetItem(Target, iconTexture: 1u);
root.AddChild(slot);
root.OnMouseMove(20, 20);
var c = new CursorFeedbackController(); // no ItemInteractionController — TargetMode.None throughout
var feedback = c.Update(root);
Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind);
}
/// <summary>Same #411 fix, combat mode: the found flag is mode-
/// independent, so an item hover under an active combat stance shows
/// MeleeOrMissileFound, not the plain (not-found) MeleeOrMissile.</summary>
[Fact]
public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InCombatMode()
{
var root = new UiRoot { Width = 800, Height = 600 };
var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 };
slot.SetItem(Target, iconTexture: 1u);
root.AddChild(slot);
root.OnMouseMove(20, 20);
var c = new CursorFeedbackController(combatModeProvider: () => CombatMode.Melee);
var feedback = c.Update(root);
Assert.Equal(RetailGlobalCursorKind.MeleeOrMissileFound, feedback.GlobalKind);
}
[Fact]
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
{

View file

@ -589,4 +589,148 @@ public sealed class RetailTooltipPresenterTests
if (FindById(child, datElementId) is { } found) return found;
return null;
}
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @0x004E5AD0: edge-fired (no dwell wait), gated by PlayerModule::
// ShowTooltips, uses the fixed popup-skin pair every game-code
// SetTooltip caller in this family shares (see RetailTooltipPresenter's
// own doc note on why UIElement_SmartBoxWrapper's own P0x47/P0x48
// cannot be read from the installed DAT).
private const uint WorldFoundGuid = 0x80000123u;
[Fact]
public void WorldHover_ShowsImmediately_NoDwellWait()
{
var (root, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null;
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
// A single Tick — no root.Tick dwell timer involved at all, unlike
// every UI-element case above.
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
Assert.Single(requests, r => r == (0x21000041u, 0x10000395u));
}
[Fact]
public void WorldHover_HidesWhenTheFoundGuidClears()
{
var (root, presenter, _) = CreateHarness();
uint? found = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => found;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
found = null;
presenter.Tick();
Assert.Equal(childrenBefore, root.Children.Count);
}
[Fact]
public void WorldHover_ShowTooltipsOff_ShowsNothing()
{
// PlayerModule::ShowTooltips @0x004E5D21 gates the whole block —
// UpdateCursorState (the found-cursor swap) is NOT gated by it, but
// that is a separate mechanism this presenter does not own.
var (root, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => false;
int childrenBefore = root.Children.Count;
presenter.Tick();
Assert.Empty(requests);
Assert.Equal(childrenBefore, root.Children.Count);
}
[Fact]
public void WorldHover_NoNameResolved_ShowsNothing()
{
var (_, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => null;
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
Assert.Empty(requests);
}
[Fact]
public void WorldHover_SuppressedWhileHoveringAUiElement()
{
// FindObject @0x004E5430: m_pElementLastOver != null routes through
// the UI-item special case or falls through to the 3D raycast —
// either way the found-object pipeline here must not also fire for
// whatever the mouse is currently over. This port narrows that to
// "no UI element hovered at all" (see the class's own doc note).
var (root, presenter, requests) = CreateHarness();
var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
root.AddChild(uiElement);
root.OnMouseMove(110, 110);
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
Assert.Empty(requests);
}
[Fact]
public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge()
{
// RecvNotice_SmartBoxObjectFound only re-runs when SmartBox::
// set_found_object's target actually changes — a per-frame poll of
// the SAME found id must not re-read ShowTooltips or re-resolve the
// name every tick.
var (_, presenter, requests) = CreateHarness();
int gateReads = 0, nameReads = 0;
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; };
presenter.WorldTooltipsEnabled = () => { gateReads++; return true; };
presenter.Tick();
presenter.Tick();
presenter.Tick();
Assert.Equal(1, gateReads);
Assert.Equal(1, nameReads);
Assert.Single(requests);
}
[Fact]
public void WorldHover_TextIsPlainAppropriateName_NoStackCountPrefix()
{
// RecvNotice_SmartBoxObjectFound's own text-building block
// (@0x004E5D3B-@0x004E5D74) has no "%d %s" stack-count logic —
// unlike UIElement_UIItem::UpdateTooltip's item-cell tooltip. The
// resolver contract here is plain GetAppropriateName, not
// GetTooltipDisplayName; this pin just documents the caller's
// resolver is free to return whatever plain text it wants and the
// presenter applies it verbatim (no separate count formatting is
// ever added by this class).
var (root, presenter, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "Iron Bars";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick();
UiElement popup = Assert.Single(root.Children);
UiElement? textChild = FindById(popup, TextChildId);
Assert.IsType<UiText>(textChild);
}
}

View file

@ -217,6 +217,125 @@ public sealed class TooltipLiveDatTests
private const uint OptionsToggleRowTemplateId = 0x10000218u;
private const uint OptionsToggleCheckboxId = 0x10000219u;
/// <summary>Retail's <c>UIElement_UIItem</c> registered class id
/// (<c>UIElement_UIItem::Register @0x0047A488</c>:
/// <c>RegisterElementClass(0x10000032, ...)</c>). The catalog also holds a
/// handful of type-3 (plain UIRegion) housekeeping elements used only as
/// BaseElement bases for the real prototypes below — never selected by any
/// list's own cell-template attribute, so they are excluded from this
/// scan.</summary>
private const uint UiItemElementType = 0x10000032u;
/// <summary>
/// Item-tooltip investigation (docs/ISSUES.md #409 follow-on): pins the
/// finding that justifies <c>UiItemSlot</c> hardcoding a single popup-
/// locator pair rather than reading it per prototype. Every standalone
/// UIItem prototype (type <c>0x10000032</c>) in the shared cell-template
/// catalog (<see cref="ItemListCellTemplate.CatalogLayoutId"/>, LayoutDesc
/// <c>0x21000037</c>) resolves the SAME <c>P0x47/P0x48</c> pair through
/// catalog inheritance — <c>0x10000395</c> within <c>0x21000041</c>, one of
/// the four popup skins <see cref="TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild"/>
/// already pins. None author literal <c>P0x49</c> text or the <c>P0x4B</c>
/// on-bit — matching retail's runtime-text game-code sites
/// (<c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c> sets both the text
/// and the on-bit itself; see <see cref="RetailTooltipPresenter.OnTooltipShow"/>'s
/// <c>fromRuntime</c> bypass).
/// </summary>
[InstalledDatFact]
public void UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
ElementInfo? catalog = LayoutImporter.ImportInfos(dats, ItemListCellTemplate.CatalogLayoutId);
Assert.NotNull(catalog);
Assert.True(catalog!.Children.Count >= 40,
$"expected the shared UIItem catalog to hold dozens of prototypes, found {catalog.Children.Count}.");
var prototypes = catalog.Children.Where(c => c.Type == UiItemElementType).ToList();
Assert.True(prototypes.Count >= 30,
$"expected the shared UIItem catalog to hold dozens of type-0x10000032 prototypes, found {prototypes.Count}.");
foreach (ElementInfo prototype in prototypes)
{
Assert.True(prototype.TooltipRootElementId == UiItemTooltipRootElementId,
$"prototype 0x{prototype.Id:X8} authors P0x47=0x{prototype.TooltipRootElementId:X8}, expected 0x{UiItemTooltipRootElementId:X8}.");
Assert.True(prototype.TooltipLayoutDid == TooltipCatalogLayoutId,
$"prototype 0x{prototype.Id:X8} authors P0x48=0x{prototype.TooltipLayoutDid:X8}, expected 0x{TooltipCatalogLayoutId:X8}.");
Assert.False(prototype.TooltipText.HasValue,
$"prototype 0x{prototype.Id:X8} unexpectedly authors literal P0x49 tooltip text.");
}
// Concrete owning lists select DIFFERENT prototype ids (attribute
// 0x1000000E) for their own cell shape, but every one of those
// prototypes still resolves the same popup locator above — so
// UiItemSlot's hardcoded pair is correct regardless of which list
// spawned the cell.
ElementInfo? inventoryTree = LayoutImporter.ImportInfos(dats, 0x21000023u);
ElementInfo? contentsGrid = inventoryTree is null
? null : AllDescendants(inventoryTree).FirstOrDefault(x => x.Id == 0x100001C6u);
Assert.NotNull(contentsGrid);
Assert.True(contentsGrid!.TryGetEffectiveProperty(0x1000000Eu, out UiPropertyValue protoProp));
Assert.NotEqual(0u, (uint)protoProp.UnsignedValue);
ElementInfo? toolbarTree = LayoutImporter.ImportInfos(dats, 0x21000016u);
ElementInfo? toolbarSlot = toolbarTree is null
? null : AllDescendants(toolbarTree).FirstOrDefault(x => x.Id == 0x100001A7u);
Assert.NotNull(toolbarSlot);
Assert.True(toolbarSlot!.TryGetEffectiveProperty(0x1000000Eu, out UiPropertyValue toolbarProtoProp));
Assert.NotEqual(0u, (uint)toolbarProtoProp.UnsignedValue);
// Different lists really do select different prototypes.
Assert.NotEqual((uint)protoProp.UnsignedValue, (uint)toolbarProtoProp.UnsignedValue);
}
/// <summary>
/// World-object tooltip investigation (docs/ISSUES.md #409 follow-on).
/// <c>UIElement_SmartBoxWrapper</c> (retail's registered class
/// <c>0x10000030</c>, <c>UIElement_SmartBoxWrapper::Register @0x0047A47E</c>)
/// is the caller of the world-hover tooltip's own
/// <c>UIElement::SetTooltip</c>/<c>StartTooltipAtMouse</c> pair
/// (<c>RecvNotice_SmartBoxObjectFound @0x004E5AD0</c>, calls at
/// <c>@0x004E5D74</c>/<c>@0x004E5DFB</c>) — but this exhaustive sweep of
/// every installed <c>LayoutDesc</c> found ZERO elements of that type
/// anywhere. Unlike the UIItem catalog (49 standalone template
/// prototypes, all authoring the SAME popup locator), the 3D-viewport
/// wrapper is evidently constructed directly by game code
/// (<c>gmGamePlayUI</c>'s own mode setup) rather than from a walkable
/// authored <c>ElementDesc</c>, so its own <c>P0x47</c>/<c>P0x48</c>
/// cannot be read from the DAT the way every other tooltip trigger's
/// can. <see cref="RetailTooltipPresenter"/>'s world-hover popup therefore
/// REUSES the item-catalog's confirmed uniform pair
/// (<c>P0x47=0x10000395</c>/<c>P0x48=0x21000041</c>) — the SAME "generic
/// runtime-text" skin every other game-code <c>SetTooltip</c> caller in
/// this family (items, the Options checkboxes, the radar) draws from —
/// as the best-evidenced inference rather than leaving world-object
/// tooltips unimplemented over one unrecoverable hex constant.
/// </summary>
[InstalledDatFact]
public void SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
int found = 0;
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
{
ElementInfo? tree;
try { tree = LayoutImporter.ImportInfos(dats, layoutId); }
catch { continue; }
if (tree is null) continue;
found += AllDescendants(tree).Count(e => e.Type == SmartBoxWrapperElementType);
}
Assert.Equal(0, found);
}
/// <summary>Retail's <c>UIElement_SmartBoxWrapper</c> registered class id.</summary>
private const uint SmartBoxWrapperElementType = 0x10000030u;
/// <summary>The item-cell popup-locator P0x47, hardcoded onto every
/// <see cref="UiItemSlot"/> — see that class's own doc comment.</summary>
private const uint UiItemTooltipRootElementId = 0x10000395u;
private static IEnumerable<UiElement> AllWidgets(UiElement root)
{
yield return root;