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 ## #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 **Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
lead's scope addition; user report: "the cursor should light up when I hover over an user's answer to the open question below ("the POINTER changes, like it already
item in inventory. it does not") 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) **Severity:** LOW (cosmetic/affordance; no gameplay impact)
**Depends on:** nothing — the hover dispatch it needs is already correct (see #409's **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). 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`.** **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: 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) ## #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) **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 **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 spin arrows — longer text, should WRAP rather than run off-screen; (7) the
Heritage/Profession/Skills/Town/Summary tab buttons. 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 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 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 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 click-to-dismiss is expected — only moving off the control, or a very long
hold, closes it. 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 **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, d.Actions.Selection,
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)), text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), 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( Confirmations: new ConfirmationRuntimeBindings(
(type, context, accepted) => (type, context, accepted) =>
late.Session.CurrentSession?.SendConfirmationResponse( late.Session.CurrentSession?.SendConfirmationResponse(

View file

@ -109,17 +109,32 @@ public sealed class CursorFeedbackController
UiElement? hover = root.Pick(root.MouseX, root.MouseY); UiElement? hover = root.Pick(root.MouseX, root.MouseY);
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off // Retail UpdateCursorState (0x00564630) keys EVERY mode's cursor off
// the SmartBox found object — the WORLD entity under the cursor. A UI // the SAME SmartBox found-object flag, computed once at the top of
// window occludes the world (no found object → pending). The one // the function (ebx = SmartBox::get_found_object_id() != 0,
// UI-side source retail-style cells contribute is an occupied item // @0x00564642) and read verbatim by every later branch — target mode
// slot's own item. // 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); RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
uint hoverTarget = hover is null uint hoverTarget = hover is null
? _worldTargetProvider?.Invoke() ?? 0u ? _worldTargetProvider?.Invoke() ?? 0u
: targetMode == RetailCursorTargetMode.UseTarget : FindHoveredItemSlot(hover)?.ItemId ?? 0u;
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
: 0u;
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
&& hoverTarget != 0 && hoverTarget != 0
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget) ? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)

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

@ -173,20 +173,36 @@ public sealed class RetailTooltipPresenter : IDisposable
if (layoutDid == 0u) if (layoutDid == 0u)
return; 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; ImportedLayout? layout;
try try
{ {
layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId); layout = _createLayout(layoutDid, rootElementId);
} }
catch (Exception error) catch (Exception error)
{ {
Console.WriteLine( Console.WriteLine(
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} " $"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}"); + $"root=0x{rootElementId:X8} failed to build: {error.Message}");
return; return false;
} }
if (layout is null) if (layout is null)
return; return false;
UiElement root = layout.Root; UiElement root = layout.Root;
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u 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 // show an empty, unsized 30x30 bevel artifact instead of retail's
// silent no-op. // silent no-op.
if (textChild is not UiText text) if (textChild is not UiText text)
return; return false;
// F4: null the per-frame anchor recompute on BOTH the popup root and // F4: null the per-frame anchor recompute on BOTH the popup root and
// its text child before resizing, the same shape the sibling // its text child before resizing, the same shape the sibling
@ -216,7 +232,7 @@ public sealed class RetailTooltipPresenter : IDisposable
text.LayoutPolicy = null; text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None; text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, tooltipText!); ApplyTooltipText(root, text, tooltipText);
SetClickThroughRecursive(root); SetClickThroughRecursive(root);
PositionAtMouse(root); PositionAtMouse(root);
@ -224,7 +240,7 @@ public sealed class RetailTooltipPresenter : IDisposable
_host.AddChild(root); _host.AddChild(root);
_host.BringToFront(root); _host.BringToFront(root);
_popupRoot = root; _popupRoot = root;
_owner = widget; return true;
} }
private void OnTooltipHide(UiElement widget) private void OnTooltipHide(UiElement widget)
@ -240,6 +256,122 @@ public sealed class RetailTooltipPresenter : IDisposable
_host.RemoveChild(_popupRoot); _host.RemoveChild(_popupRoot);
_popupRoot = null; _popupRoot = null;
_owner = 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. /// <summary>Force-hides whatever tooltip is currently showing, if any.
@ -371,6 +503,8 @@ public sealed class RetailTooltipPresenter : IDisposable
{ {
if (_popupRoot is not null) if (_popupRoot is not null)
_host.BringToFront(_popupRoot); _host.BringToFront(_popupRoot);
UpdateWorldHoverTooltip();
} }
public void Dispose() public void Dispose()

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

@ -333,6 +333,17 @@ public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback, CursorFeedbackController Feedback,
RetailCursorManager Manager); 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( public sealed record ConfirmationRuntimeBindings(
Action<uint, uint, bool> SendResponse); Action<uint, uint, bool> SendResponse);
@ -435,6 +446,7 @@ public sealed record RetailUiRuntimeBindings(
ExternalContainerRuntimeBindings ExternalContainer, ExternalContainerRuntimeBindings ExternalContainer,
VendorRuntimeBindings Vendor, VendorRuntimeBindings Vendor,
RetailUiCursorBindings Cursor, RetailUiCursorBindings Cursor,
WorldTooltipRuntimeBindings WorldTooltip,
ConfirmationRuntimeBindings Confirmations, ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal, AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options, 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) if (_bindings.Chat.Store is { } store)
{ {

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>

View file

@ -308,6 +308,51 @@ public sealed class CursorFeedbackControllerTests
Assert.False(feedback.Cursor.IsValid); 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] [Fact]
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered() public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
{ {

View file

@ -589,4 +589,148 @@ public sealed class RetailTooltipPresenterTests
if (FindById(child, datElementId) is { } found) return found; if (FindById(child, datElementId) is { } found) return found;
return null; 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 OptionsToggleRowTemplateId = 0x10000218u;
private const uint OptionsToggleCheckboxId = 0x10000219u; 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) private static IEnumerable<UiElement> AllWidgets(UiElement root)
{ {
yield return root; yield return root;