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

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