feat(ui): world-object hover tooltip — UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound port

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 (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 ground pile of arrows shows "Arrows", not
"20 Arrows" — a real, decomp-confirmed asymmetry).

Ported as RetailTooltipPresenter.UpdateWorldHoverTooltip, driven by
the SAME world-hover pick CursorFeedbackController's own found-cursor
already uses (WorldSelectionQuery.PickAtCursor, includeSelf: true —
own player is included on that precedent) and the SAME
ClientObjectTable-backed name resolver SocialAllegiancePageController's
ResolveWorldObjectName already established as this codebase's
pattern. New WorldTooltipRuntimeBindings threads it through
RetailUiRuntimeBindings; wired at InteractionRetainedUiComposition
alongside the existing cursorFeedback construction.

Queried only when no UI element is hovered — a narrowing from
retail's literal "raycast even under non-item UI chrome" (FindObject's
m_pElementLastOver check), called out in the class's own doc note as
a scoped interpretation rather than a byte-exact port.

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. This
port reuses the same P0x47=0x10000395/P0x48=0x21000041 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.

Live-verified against a connected ACE session (session-config launch,
+Acdream): hovering a "Silver Tusker" near spawn mounted the correct
popup text and simultaneously flipped the cursor to its DefaultFound
variant, confirming the shared found-object pipeline drives both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 23:02:29 +02:00
parent e29c61a3a4
commit fe1bc70753
5 changed files with 436 additions and 8 deletions

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

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

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