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:
parent
e29c61a3a4
commit
fe1bc70753
5 changed files with 436 additions and 8 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue