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
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue