acdream/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
Erik 35abbe1d0d
All checks were successful
CI / linux-portable (push) Successful in 3m29s
CI / windows-gate (push) Successful in 5m44s
CI / release (push) Successful in 2m19s
fix #430: tooltip wrap and draw honor the text child's authored margins
Third owner-screenshot round: acdream fit one more word per line than
retail and drew glyphs flush against the popup's right border. Retail's
InqSizewMargins @0x00469660 wraps the glyph list at
(bound - m_margL - m_margR) and adds the margins back into the measured
width; the popup skins' shared text child 0x10000396 authors margins
L=2/R=2 (U=2/D=2 on three of the four skins — live-DAT probed). The
presenter now subtracts the horizontal margins from both wrap passes,
re-adds them into the measured width used for root sizing, and counts
the vertical margins in the measured/re-wrapped heights; the widget's
own draw already insets by all four margins (UiText ContentOffsetX +
the top/bottom inset), so the right-side spacing returns for free.

TooltipSkinLiveDatTests pins the authored margins per skin alongside
the P0x3D=256 wrap bound; a new presenter test proves margins shrink
the wrap bound and survive onto the widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:44:28 +02:00

803 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Linq;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// #409 — client-wide retail hover-tooltip system. Consumes <see cref="UiRoot"/>'s
/// existing hover-dwell timer (<see cref="UiRoot.TooltipShow"/>/
/// <see cref="UiRoot.TooltipHide"/>) and, for any widget that authors the five
/// tooltip properties, instantiates retail's popup LayoutDesc, auto-sizes and
/// positions it, and mounts/unmounts it as a topmost sibling of the retained tree.
///
/// <para>
/// Retail mechanism (decomp anchors — see also each <c>ElementInfo.Tooltip*</c>
/// field's own doc comment for the individual property citations):
/// <list type="bullet">
/// <item><description><c>UIElementManager::CheckTooltip @0x0045B6E0</c> — the
/// per-frame dwell/auto-hide timer, ported into <see cref="UiRoot.Tick"/>.</description></item>
/// <item><description><c>UIElement::MouseHover @0x00462520</c> — the per-element
/// gate (<c>P0x4B</c> TooltipOn bit + the global <c>m_tooltipEnable</c>
/// preference); ported as this class's <see cref="Enabled"/> +
/// <see cref="UiElement.AuthoredTooltipEnabled"/> check.</description></item>
/// <item><description><c>UIElement::StartTooltipAtMouse @0x00460D70</c> — text
/// resolution (<c>P0x49</c>) and dispatch to the manager.</description></item>
/// <item><description><c>UIElementManager::StartTooltip @0x0045DE90</c> —
/// instantiates the popup (<c>P0x47</c> root element id within <c>P0x48</c>'s
/// LayoutDesc), resolves the text child (<c>P0x4A</c>, read off the POPUP's own
/// root), sets its text, and auto-resizes the root by the measured-vs-authored
/// text delta.</description></item>
/// <item><description><c>UIElementManager::StartTooltip @0x00459700</c> —
/// positions the popup's top-left at the mouse cursor OFFSET by 32px on
/// BOTH axes (<c>@0x00459739</c>/<c>@0x00459747</c>), clamped to the
/// display.</description></item>
/// </list>
/// </para>
///
/// <para>
/// Live-DAT-probed structure (#409 investigation): layout <c>0x21000041</c>'s
/// root (id 0, 800x600, a pure catalog container) holds four 30x30 popup skins
/// (<c>0x10000487</c>/<c>0x10000395</c>/<c>0x10000397</c>/<c>0x10000398</c>),
/// each a four-piece bevel frame around one Type-12 text child
/// <c>0x10000396</c> — every one of those four roots' own <c>P0x4A</c> resolves
/// to exactly <c>0x10000396</c>, confirming the decomp reading. 430 installed
/// elements author at least one of the five properties (243 with literal
/// <c>P0x49</c> text this port shows; the other 187 have no literal text —
/// see register row TS-85, corrected at the 2026-08-16 review round: retail's
/// OWN default <c>InqProperty(0x49)</c> reads the SAME authored bags this
/// port already reads, so most of those 187 show nothing in retail either;
/// the real gap is the separate <c>m_TTText</c>/<c>SetTooltip</c> family
/// headed by the <c>P0xD0</c> truncated-text auto-tooltip). A fifth root id
/// (<c>0x100001F0</c>) and a second popup layout (<c>0x21000026</c>) also
/// appear on a handful of elements; this class is fully data-driven off
/// each widget's own authored properties, so neither needed special-casing.
/// </para>
/// </summary>
public sealed class RetailTooltipPresenter : IDisposable
{
private readonly UiRoot _host;
/// <summary>Resolves a tooltip popup's LayoutDesc + root element (the
/// widget's own <see cref="UiElement.AuthoredTooltipLayoutDid"/> /
/// <see cref="UiElement.AuthoredTooltipRootElementId"/>) to a mounted-ready
/// <see cref="ImportedLayout"/>. Wraps <c>LayoutImporter.Import</c> with the
/// runtime's dat lock/resolve/font context — the same shape
/// <c>RetailUiRuntime.CreateLayout</c> already gives <see cref="RetailDialogFactory"/>.</summary>
private readonly Func<uint, uint, ImportedLayout?> _createLayout;
private UiElement? _popupRoot;
private UiElement? _owner;
private bool _disposed;
public RetailTooltipPresenter(UiRoot host, Func<uint, uint, ImportedLayout?> createLayout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
_host.TooltipShow += OnTooltipShow;
_host.TooltipHide += OnTooltipHide;
}
/// <summary>
/// The client-side <c>Misc.TooltipEnable</c> preference
/// (<c>UIElementManager::Init @0x0045EE10</c> registers the LIVE binding
/// via <c>UserPreferences::RegisterPreference @0x0045EE92</c> — see
/// <see cref="AcDream.UI.Abstractions.Panels.Settings.MiscSettings"/>'s
/// own doc comment for the full two-mechanism citation split —
/// default true — <c>m_tooltipEnable=1 @0x0045F756</c>). NOT part of the
/// server-synced <c>CharacterOptionTable</c> — retail's 2013 Config tab
/// doesn't expose a row for it either (research confirms it's UserPreferences-
/// only, absent from the visible tab), so this stays a plain client-local
/// setting on the presenter rather than routing through
/// <c>RuntimeCharacterOptionsState</c>. Gates ONLY the popup presentation —
/// matching retail's own gate point at <c>UIElement::MouseHover</c> — NOT
/// <see cref="UiRoot"/>'s dwell timer, which keeps running either way exactly
/// as retail's <c>CheckTooltip</c> does.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Retail <c>UIElement::StartTooltipAtMouse @0x00460D70</c>'s text source, in
/// retail's own order: the RUNTIME <c>m_TTText</c> first
/// (<c>@0x00460DA3</c> tests <c>StringInfo::IsValid(&amp;m_TTText)</c> and takes
/// it verbatim at <c>@0x00460DAA</c>), and only when that is empty does it fall
/// back to the AUTHORED <c>P0x49</c> property (<c>@0x00460DDF</c>
/// <c>InqProperty(0x49)</c>).
///
/// <para>
/// <c>UiElement.GetTooltipText()</c> is this port's <c>m_TTText</c>: it is what
/// every acdream analog of retail's ~15 game-code <c>UIElement::SetTooltip</c>
/// call sites already writes — the Options panel's per-row
/// <c>ID_PlayerOption_*_Help</c> strings
/// (<c>UIOption_CheckboxBitfield64::CreateChildren @0x00485E65</c>'s
/// <c>siTooltip</c> array; acdream <c>CharacterOptionsPageController</c>,
/// <c>ChatOptionsPageController</c>, <c>ConfigOptionsPageController</c>,
/// <c>UiCheckboxBitfield64</c>), the Configure-Keyboard key captions, and the
/// social pages' checkbox help. Reading only <see cref="UiElement.AuthoredTooltipText"/>
/// (as this class did before) is why NONE of those showed live: retail's
/// in-world panels author the popup LOCATOR (<c>P0x47</c>/<c>P0x48</c>) and the
/// <c>P0x4B</c> on-bit but deliberately author NO <c>P0x49</c> text, because the
/// text arrives at runtime. Live-DAT measured: the Options toggle row
/// (<c>0x2100002B</c>/<c>0x10000218</c>) authors
/// <c>P0x47=0x10000397 P0x48=0x21000041 P0x4B=true P0x49=&lt;empty&gt;</c>.
/// </para>
/// </summary>
private static string? ResolveTooltipText(UiElement widget, out bool fromRuntime)
{
string? runtime = widget.GetTooltipText();
if (!string.IsNullOrEmpty(runtime))
{
fromRuntime = true;
return runtime;
}
fromRuntime = false;
return widget.AuthoredTooltipText;
}
private void OnTooltipShow(UiElement widget)
{
RemovePopup();
if (!Enabled)
return;
string? tooltipText = ResolveTooltipText(widget, out bool fromRuntime);
if (string.IsNullOrEmpty(tooltipText))
return;
// The P0x4B TooltipOn bit (UIElement::MouseHover @0x0046254C reads
// __bitfield164 bit 5). Retail's game-code SetTooltip sites do not rely on
// the authored bit — each one SETS it in the same breath as the text:
// UIElement_UIItem::UpdateTooltip @0x004E1D5E, gmPaperDollUI::
// UpdateItemSlotTooltip @0x004A52F4, gmSpellcastingUI::UpdateEndowmentIcon
// @0x004C63AC, SpellCastSubMenu::UpdateFromPlayerModule @0x004C67ED,
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004C7000, SpellCastSubMenu::
// AddFavorite @0x004C7218, gmRadarUI::DrawObjects @0x004D9617, and
// UIElement_Text::RecalculateTruncation @0x00467076 — all `|= 0x20`, each
// paired with a `&= ~0x20` on the clearing path. So a widget carrying
// runtime tooltip text is tooltip-on by construction; only the AUTHORED-
// text path consults the authored bit.
if (!fromRuntime && !widget.AuthoredTooltipEnabled)
return;
if (widget.AuthoredTooltipRootElementId == 0u)
return;
// StartTooltipAtMouse @0x00460E6B reads P0x48 and, when it is absent
// (@0x00460E7E), substitutes the element's OWN LayoutDesc DID
// (this->m_layout->m_DID) before dispatching to StartTooltip. Only when
// BOTH are absent (@0x00460E91) does it give up.
uint layoutDid = widget.AuthoredTooltipLayoutDid != 0u
? widget.AuthoredTooltipLayoutDid
: widget.SourceLayoutDid;
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.
///
/// <para>
/// Night-round review F5: the single-popup invariant (retail's own
/// single <c>m_pTooltipElement</c> slot) is now enforced HERE,
/// structurally, rather than relying on every caller to have already
/// cleared a stale popup before reaching this method. Both existing
/// callers already clear on their own early-return paths too (a hover
/// change that resolves to no valid tooltip text must still tear down
/// the PREVIOUS popup, which never reaches this method at all), so
/// those calls stay — this is a belt-and-braces guarantee, not a
/// replacement for them. It closes a real hole: <see cref="UpdateWorldHoverTooltip"/>'s
/// own clear is gated on <c>_worldTooltipShowing</c> (only true when the
/// WORLD path itself mounted the current popup) and its "a UI popup
/// cannot be showing here" comment assumed <see cref="_host"/>'s hover
/// query is null whenever that branch runs — an assumption that does
/// not hold the instant a modal dialog opens over a stationary cursor:
/// the UI dwell popup from <see cref="OnTooltipShow"/> stays mounted
/// (<c>_owner</c>/<c>_popupRoot</c> set, <c>_worldTooltipShowing</c>
/// still false) while the world path could independently find an
/// object and call this method, mounting a second popup on top. Now it
/// cannot: this call clears whatever is mounted, UI-owned or
/// world-owned, before either ever gets a chance to layer.
/// </para>
/// </summary>
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
{
RemovePopup();
ImportedLayout? layout;
try
{
layout = _createLayout(layoutDid, rootElementId);
}
catch (Exception error)
{
Console.WriteLine(
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
+ $"root=0x{rootElementId:X8} failed to build: {error.Message}");
return false;
}
if (layout is null)
return false;
UiElement root = layout.Root;
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
? layout.FindElement(root.AuthoredTooltipTextChildElementId)
: null;
// F5: retail requires GetChildRecursive to resolve AND DynamicCast
// to UIElement_Text (type 0xc) before it ever calls the positioning/
// show half of StartTooltip (UIElementManager::StartTooltip
// @0x0045DE90, @0x0045df59/@0x0045df65 — @0x0045df6f gates the rest
// of the function on a non-null cast result). Every popup skin this
// port's live-DAT sweep found resolves cleanly, so this only guards
// a malformed/future LayoutDesc — but mounting anyway there would
// show an empty, unsized 30x30 bevel artifact instead of retail's
// silent no-op.
if (textChild is not UiText text)
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
// RetailMessageDialogView uses for its popup/message pair
// (RetailMessageDialogView.cs:49-53) — ApplyAnchor otherwise
// recomputes margins against the popup's authored edge mode every
// frame, which would silently fight the resize below if that mode
// isn't the assumed "no stretch" default.
root.LayoutPolicy = null;
root.Anchors = AnchorEdges.None;
text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, tooltipText);
SetClickThroughRecursive(root);
PositionAtMouse(root);
_host.AddChild(root);
_host.BringToFront(root);
_popupRoot = root;
return true;
}
private void OnTooltipHide(UiElement widget)
{
if (ReferenceEquals(_owner, widget))
RemovePopup();
}
private void RemovePopup()
{
if (_popupRoot is null)
return;
_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), CORRECTED at the
// 2026-08-17 morning gate round (user finding 1: our world tooltips
// popped instantly; retail's "lag"). The night round misread the notice
// as edge-MOUNTING ("SetTooltip + StartTooltipAtMouse IMMEDIATELY ... no
// dwell wait"): the immediate StartTooltipAtMouse @0x004E5DFB is inside
// `if (UIElementManager::s_pInstance->m_dragElement != 0)` (@0x004E5D8E)
// — m_dragElement is a real, distinct PDB field (acclient.h's
// UIElementManager, separate from the m_pTooltipElement family), so the
// immediate mount fires ONLY while a drag-and-drop is in progress
// ("what am I about to drop this on"). In the ordinary hover case the
// notice merely STAGES the name (UIElement::SetTooltip @0x004E5D74 +
// `|= 0x20` TooltipOn @0x004E5D79) on the wrapper, and the DISPLAY rides
// the standard per-frame dwell machinery:
//
// UIElementManager::CheckTooltip @0x0045B6E0 (per frame, hover not
// started): mouse idle since m_lastMouseMoveTime (stamped on EVERY
// move, MouseMoveHandler @0x0045e736) for >= m_tooltipDelay (0.25 s
// default @0x0045f75d; the runtime-constructed wrapper authors no
// P0x50 override) while entered over the wrapper with no capture
// (@0x0045b715) -> StartHover @0x00459250 -> the wrapper's inherited
// UIElement::MouseHover @0x00462520 (TooltipOn bit + m_tooltipEnable)
// -> StartTooltipAtMouse reads the staged m_TTText -> popup mounts.
//
// Found-object CHANGES while a popup is up tear it down via SetTooltip's
// OWN text-change teardown (@0x004617FF: owner == this && popup != null
// -> ResetTooltip @0x0045C360, which tail-calls CheckTooltip) — so with
// an IDLE mouse the replacement popup mounts the SAME frame (the dwell
// deadline long passed), while a MOVING mouse keeps pushing the deadline
// out and shows nothing until it rests. found -> 0 stages EMPTY text
// (ClearTooltip @0x004E5E30 = SetTooltip(empty) @0x004625F9): the same
// teardown fires and nothing remounts. The ShowTooltips gate
// (PlayerModule::ShowTooltips @0x004E5D21, CharacterOptionId.ShowTooltips)
// and the name resolve (@0x004E5D3B) happen at the EDGE, exactly where
// retail reads them. The text is ACCWeenieObject::GetObjectName(id,
// NAME_APPROPRIATE, 0) — 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 (SharedPopupSkinRootElementId/
// SharedPopupSkinLayoutDid 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>
/// The shared popup-skin locator pair every tooltip-bearing surface
/// that authors no locator of its own resolves to. Retail's shared
/// UIItem cell-template catalog (<c>ItemListCellTemplate.CatalogLayoutId</c>,
/// LayoutDesc <c>0x21000041</c>) authors the SAME
/// <c>P0x47=0x10000395</c>/<c>P0x48=0x21000041</c> pair on all 49 of
/// its standalone item-cell prototypes (live-DAT-probed 2026-08-16:
/// inventory's 32x32 cell, the toolbar's per-slot prototypes, the
/// container cell, every paperdoll/armor slot skin —
/// <c>TooltipLiveDatTests.PopupSkinRootIds</c>/
/// <c>UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator</c>) —
/// one of the four 30x30 popup skins this presenter mounts for every
/// authored tooltip-bearing element too.
/// </summary>
/// <remarks>
/// Night-round review F10: previously duplicated as three separate
/// private constants with three separate partial citations; this is the
/// ONE citation. Two consumers remain — this class's own world-hover
/// popup (below) and <c>UiItemSlot</c>'s item-cell popup.
/// <c>MapPageController</c>'s town markers were REMOVED from the list at
/// the 2026-08-17 morning gate round (finding 3): the hotspot template
/// (<c>0x100001F0</c> in <c>0x21000026</c>) turned out to author its OWN
/// popup locator (<c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c> — the
/// fourth skin, whose incorporated text child fonts <c>0x40000015</c>
/// where the other three font <c>0x40000002</c>), which the earlier
/// shared-constant override was clobbering. This class is the natural
/// owner since it's the mount point every consumer ultimately routes
/// through (<c>OnTooltipShow</c>/<c>UpdateWorldHoverTooltip</c> both call
/// <c>TryBuildAndMountPopup</c> with these values or a widget's own).
/// </remarks>
public const uint SharedPopupSkinRootElementId = 0x10000395u;
public const uint SharedPopupSkinLayoutDid = 0x21000041u;
private uint _worldHoverGuid;
private bool _worldTooltipShowing;
/// <summary>The wrapper's staged <c>m_TTText</c> — written at the
/// found-object EDGE (<c>SetTooltip @0x004E5D74</c> /
/// <c>ClearTooltip @0x004E5E30</c>), displayed only when the dwell
/// machinery mounts it. Null/empty = cleared (retail's empty
/// <c>StringInfo</c>; <c>StartTooltipAtMouse @0x00460DA3</c>'s
/// <c>IsValid</c> test fails and the wrapper authors no <c>P0x49</c>
/// fallback, so nothing shows).</summary>
private string? _worldStagedText;
/// <summary>When the world popup mounted — retail
/// <c>m_tooltipStart</c>, for the <c>m_tooltipDuration</c> (10 s)
/// auto-hide (<c>CheckTooltip @0x0045b78a</c>).</summary>
private long _worldTooltipShownMs;
/// <summary>Set by the duration auto-hide: retail's expiry path calls
/// <c>SwitchMouseOver(this, nullptr) @0x0045b7b2</c>, clearing
/// <c>m_pElementLastEntered</c> — the dwell cannot re-arm until the next
/// mouse move re-enters the wrapper. Without this latch the port would
/// remount one frame after every auto-hide (staged text still present,
/// mouse still idle) in a 10 s flicker loop.</summary>
private bool _worldRearmRequiresMouseMove;
private int _worldLastSeenMouseX = int.MinValue;
private int _worldLastSeenMouseY = int.MinValue;
/// <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;
// Post-auto-hide re-arm: retail's duration expiry ran
// SwitchMouseOver(null); the next real mouse move re-enters the
// wrapper and only THEN can the dwell restart (see the
// _worldRearmRequiresMouseMove field doc).
if (_host.MouseX != _worldLastSeenMouseX || _host.MouseY != _worldLastSeenMouseY)
{
_worldLastSeenMouseX = _host.MouseX;
_worldLastSeenMouseY = _host.MouseY;
_worldRearmRequiresMouseMove = false;
}
uint found = _host.Pick(_host.MouseX, _host.MouseY) is null
? WorldHoverGuidProvider() ?? 0u
: 0u;
// ── The notice edge (RecvNotice_SmartBoxObjectFound @0x004E5AD0's
// tooltip half) — STAGES text; mounts nothing except mid-drag. ──
if (found != _worldHoverGuid)
{
_worldHoverGuid = found;
string? staged = _worldStagedText;
if (found == 0u || WorldTooltipsEnabled?.Invoke() != true)
{
// @0x004E5E2A/@0x004E5E30: bit &= ~0x20 + ClearTooltip.
staged = null;
}
else
{
string? name = WorldHoverNameResolver?.Invoke(found);
// @0x004E5D48: the empty-name guard skips SetTooltip
// entirely — the PREVIOUS staged text stays in place
// (retail's own shape; nameless found objects are rare).
if (!string.IsNullOrEmpty(name))
staged = name;
}
// UIElement::SetTooltip @0x004617C0: only a text CHANGE does
// anything (@0x004617D9's operator== guard). On change, the
// popup this surface currently shows is torn down
// (@0x004617FF ResetTooltip) — the dwell block below is
// ResetTooltip's tail-called CheckTooltip, re-run this same
// frame, so an IDLE mouse remounts the replacement immediately.
//
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A
// bug 1): this teardown must fire on EVERY staging edge — an
// A-found-B transition with no intervening "nothing found"
// frame previously orphaned each old popup in the tree
// (_popupRoot single-slot invariant, retail's own single
// m_pTooltipElement).
if (!string.Equals(staged, _worldStagedText, StringComparison.Ordinal))
{
_worldStagedText = staged;
if (_worldTooltipShowing)
RemovePopup();
// The drag-in-progress exception @0x004E5D8E: m_dragElement
// != 0 -> ResetTooltip + StartTooltipAtMouse IMMEDIATELY
// (@0x004E5DF0/@0x004E5DFB), no dwell — while dragging an
// item over the world you see the drop target's name at
// once. NOT gated on m_tooltipEnable (this path bypasses
// UIElement::MouseHover entirely).
if (!string.IsNullOrEmpty(staged) && _host.DragSource is not null)
{
if (TryBuildAndMountPopup(
SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, staged!))
{
_worldTooltipShowing = true;
_worldTooltipShownMs = _host.NowMs;
}
return;
}
}
}
// ── CheckTooltip @0x0045B6E0, the wrapper's share of it. ──
if (_worldTooltipShowing)
{
// Auto-hide after m_tooltipDuration (@0x0045b78a; 10 s default
// @0x0045f767, UiRoot.TooltipDurationMs). The expiry path's
// SwitchMouseOver(null) @0x0045b7b2 means no remount until the
// mouse moves again.
if (_host.NowMs - _worldTooltipShownMs >= _host.TooltipDurationMs)
{
RemovePopup();
_worldRearmRequiresMouseMove = true;
}
return;
}
// StartTooltipAtMouse @0x00460DA3: empty staged m_TTText + no
// authored P0x49 on the runtime-constructed wrapper -> nothing.
if (string.IsNullOrEmpty(_worldStagedText))
return;
// @0x0045b715: the arm branch requires no mouse capture.
if (_host.Captured is not null)
return;
if (_worldRearmRequiresMouseMove)
return;
// @0x0045b747: mouse idle since m_lastMouseMoveTime for >= the
// global m_tooltipDelay (the wrapper authors no per-element P0x50).
if (_host.MouseIdleMs < _host.TooltipDelayMs)
return;
// UIElement::MouseHover @0x0046254C's m_tooltipEnable gate — the
// dwell-mounted path IS gated on the global enable (unlike the
// drag-immediate branch above, which bypasses MouseHover).
if (!Enabled)
return;
if (TryBuildAndMountPopup(
SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, _worldStagedText!))
{
_worldTooltipShowing = true;
_worldTooltipShownMs = _host.NowMs;
}
}
/// <summary>Force-hides whatever tooltip is currently showing, if any,
/// and forgets the staged world-hover text/guid. Session-reset callers
/// use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s own role
/// for dialogs) so neither a stale popup NOR a stale staged name (which
/// the dwell would otherwise remount over the new session's world with
/// an unmoved mouse) can survive a reconnect.</summary>
public void HideCurrent()
{
RemovePopup();
_worldHoverGuid = 0u;
_worldStagedText = null;
_worldRearmRequiresMouseMove = false;
}
/// <summary>Retail <c>UIElementManager::StartTooltip @0x0045DE90</c>'s text
/// + auto-resize step. Wraps at the display width (retail's
/// <c>UIElement_Text::InqSizewMargins(..., UITS_MAX_WIDTH)</c> falls back to
/// <c>RenderDevice::GetDisplayWidth()</c> when the text element authors no
/// <c>P0x3D</c> max-width override — unmodeled here, no probed tooltip
/// element authors one), then grows the popup ROOT by exactly the delta
/// between the measured wrapped size and the text child's AUTHORED size —
/// the authored gap becomes the popup's padding. Retail's final branch
/// (grow further if the text child has vertical scroll overflow) has no
/// acdream analog for a freshly-built, unscrolled popup and is a
/// structural no-op here.
///
/// <para>
/// F8 additions (2026-08-16 review round): (1) <c>InqSizewMargins</c>
/// returns a size with the text element's own margins already added
/// back in (<c>@0x004697a4</c>/<c>@0x004697bc</c>: <c>+= m_margR +
/// m_margL</c> / <c>+= m_margD + m_margU</c>) before <c>StartTooltip</c>
/// diffs it against the AUTHORED width/height — zeroing
/// <see cref="UiText.Padding"/> here (same shape the sibling
/// <c>RetailMessageDialogView</c> uses for its own message child,
/// <c>RetailMessageDialogView.cs:53</c>) keeps our margin-free measured
/// size exactly comparable, without needing to separately track and
/// re-add an inset this port doesn't otherwise model for the tooltip
/// text child. (2) the grown size is clamped through the SAME
/// authored-override clamp <c>UIElement::ResizeTo @0x00463C30</c>
/// applies to every resize (<c>P0x3C</c> max-height/<c>P0x3E</c>
/// min-height/<c>P0x3D</c> max-width/<c>P0x3F</c> min-width, max
/// checked before min on each axis) before the value is ever assigned —
/// this port had been assigning the grown size directly, unclamped.
/// </para></summary>
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
{
float authoredTextWidth = text.Width;
float authoredTextHeight = text.Height;
text.Padding = 0f;
Func<string, float> measure = text.DatFont is { } datFont
? datFont.MeasureWidth
: text.Font is { } bitmapFont
? bitmapFont.MeasureWidth
: static s => s.Length * 8f;
// CA5-gate correction (2026-08-24, owner retail-render oracle):
// retail sizes a tooltip in TWO passes, and the second is what makes
// long tooltips multi-line. StartTooltip @0x0045DE90:
// 1. MEASURE — InqSizewMargins(..., UITS_MAX_WIDTH): wrap at the
// authored P0x3D max width, else the display width, producing
// the measured extent.
// 2. Resize the ROOT by the measured-vs-authored delta through
// ResizeTo, where the popup skin's authored max/min CLAMP.
// 3. RecalculateGlyphList — the text RE-WRAPS at its FINAL
// (possibly clamped) width.
// 4. A second ResizeTo grows the root's HEIGHT (width unchanged)
// when the re-wrapped glyph extent needs more than the text
// child's current height.
// The pre-correction port did only pass 1, so a description longer
// than the clamped popup stayed one clipped line.
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
// CA5 re-check corrections (2026-08-24, owner screenshots vs retail):
// (a) The popup skins' TEXT CHILD (0x10000396) authors P0x3D=256 —
// live-DAT probed on all four skins. InqSizewMargins'
// UITS_MAX_WIDTH branch reads GetAttribute_Int(0x3D) on the TEXT
// element BEFORE the display-width fallback, so retail wraps
// tooltip text at 256px, not the screen width. (TS-85's "zero
// elements author P0x3D" sweep only covered hover TARGETS, never
// the popup skins' text children.)
// (b) Tooltip text is LEFT-aligned: the text child authors no
// justification and retail's unauthored default is Left, while
// our importer's ElementInfo default is Center — the same
// wrong-default class as #410's VJustify finding. Point-fixed
// here (the chat transcript does the same); the client-wide
// default remains #410's scope.
text.Centered = false;
text.RightAligned = false;
// (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on
// the popup skins) participate exactly as InqSizewMargins does:
// GlyphList::Recalculate wraps at (width margL margR) and
// the measured result adds the margins back
// (@0x00469762/@0x004697..: `Recalculate(..., w margL margR)`
// then `*out += margR + margL`). Without this, line one fit one
// more word than retail (256 vs retail's 252 wrap) and the text
// drew flush against the parchment's right border.
float marginsX = text.MarginLeft + text.MarginRight;
float marginsY = text.MarginTop + text.MarginBottom;
float wrapBound = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth
? MathF.Max(1f, authoredMaxTextWidth)
: MathF.Max(1f, _host.EffectiveCanvasSize.X);
float measureWrapWidth = MathF.Max(1f, wrapBound - marginsX);
var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth);
float measuredWidth =
(measured.Count == 0 ? 0f : measured.Max(measure)) + marginsX;
float measuredHeight = measured.Count * lineHeight + marginsY;
float requestedWidth = root.Width + (measuredWidth - authoredTextWidth);
float requestedHeight = root.Height + (measuredHeight - authoredTextHeight);
// ResizeTo's own clamp order: max first, then min, independently
// per axis (@0x00463c64/@0x00463c80 for height, @0x00463c9c/
// @0x00463cba for width).
if (root.AuthoredResizeMaxHeight is { } maxHeight && requestedHeight > maxHeight)
requestedHeight = maxHeight;
if (root.AuthoredResizeMinHeight is { } minHeight && requestedHeight < minHeight)
requestedHeight = minHeight;
if (root.AuthoredResizeMaxWidth is { } maxWidth && requestedWidth > maxWidth)
requestedWidth = maxWidth;
if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth)
requestedWidth = minWidth;
float authoredRootWidth = root.Width;
float authoredRootHeight = root.Height;
root.Width = requestedWidth;
root.Height = requestedHeight;
// The text child follows the root's ACTUAL growth (retail's
// anchored resize) — the clamp is what makes these differ from the
// measured extents.
float textFinalWidth = MathF.Max(
1f, authoredTextWidth + (requestedWidth - authoredRootWidth));
if (text.AuthoredResizeMaxWidth is { } textMaxWidth)
textFinalWidth = MathF.Min(textFinalWidth, textMaxWidth);
float textFinalHeight =
authoredTextHeight + (requestedHeight - authoredRootHeight);
// Pass 3: re-wrap at the final clamped width, inside the margins
// (RecalculateGlyphList wraps glyphs at width margL margR).
var wrapped = UiText.WrapWords(
tooltipText, measure, MathF.Max(1f, textFinalWidth - marginsX));
text.LinesProvider = () => wrapped
.Select(line => new UiText.Line(line, text.DefaultColor))
.ToArray();
text.Width = wrapped.Count == 0
? 0f
: MathF.Min(textFinalWidth, wrapped.Max(measure) + marginsX);
float rewrappedHeight = wrapped.Count * lineHeight + marginsY;
// Pass 4: grow the root's height by the re-wrap's overflow beyond
// the text child's post-resize height (width unchanged), through
// the same ResizeTo clamps.
if (rewrappedHeight > textFinalHeight)
{
float grownHeight = root.Height + (rewrappedHeight - textFinalHeight);
if (root.AuthoredResizeMaxHeight is { } maxH2 && grownHeight > maxH2)
grownHeight = maxH2;
if (root.AuthoredResizeMinHeight is { } minH2 && grownHeight < minH2)
grownHeight = minH2;
root.Height = grownHeight;
}
text.Height = rewrappedHeight;
}
/// <summary>Retail <c>UIElementManager::StartTooltip @0x00459700</c>: the
/// popup's top-left lands at the mouse cursor OFFSET by <see cref="MouseOffsetPx"/>
/// on BOTH axes (<c>@0x00459739</c>: <c>mouseX + 0x20</c>; <c>@0x00459747</c>:
/// <c>mouseY + 0x20</c>), clamped so it never crosses the right/bottom
/// display edge (nor goes negative, mirroring retail's own
/// <c>max(0, ...)</c> defensive clamp, <c>@0x00459753</c>/<c>@0x00459769</c>).
/// Retail's own clamp ORDER (max-vs-display computed against the raw
/// offset mouse position, not the already-floored one — <c>@0x00459784</c>-
/// <c>@0x0045979c</c>) can go negative when the popup is bigger than the
/// display; <see cref="Math.Clamp"/> cannot express that (its <c>min</c>
/// must be <![CDATA[<=]]> <c>max</c>) — immaterial for every popup this port
/// builds (fixed 30x30 skins plus a bounded auto-grow), left unmatched.</summary>
private const float MouseOffsetPx = 32f;
private void PositionAtMouse(UiElement root)
{
var canvas = _host.EffectiveCanvasSize;
float x = Math.Clamp(_host.MouseX + MouseOffsetPx, 0, MathF.Max(0f, canvas.X - root.Width));
float y = Math.Clamp(_host.MouseY + MouseOffsetPx, 0, MathF.Max(0f, canvas.Y - root.Height));
root.Left = x;
root.Top = y;
}
/// <summary>A tooltip must never intercept the pointer — the very next
/// hover-hit-test would otherwise find the popup itself and immediately
/// dismiss it (no decomp counterpart needed: retail's tooltip is a
/// separate, non-hit-tested presentation layer by construction, per the
/// AP-229 register row's own finding on dialogs). <see cref="UiDatElement"/>
/// and <see cref="UiText"/> already default <c>ClickThrough=true</c>, but
/// this walk makes the guarantee unconditional across whatever the popup
/// LayoutDesc happens to be authored with.</summary>
private static void SetClickThroughRecursive(UiElement element)
{
element.ClickThrough = true;
foreach (UiElement child in element.Children)
SetClickThroughRecursive(child);
}
/// <summary>Re-asserts the popup's z-order above whatever
/// <see cref="RetailDialogFactory.Tick"/> raised this frame — mirrors that
/// factory's own per-tick <c>BringToFront</c> re-raise (see its own doc
/// comment on the dialog/screen sibling z-order war, register AP-229) so a
/// dialog opened WHILE a tooltip is already showing cannot bury it. Must
/// run after <see cref="RetailUiRuntime"/> ticks both
/// <see cref="RetailDialogFactory"/> and <see cref="UiRoot"/> in the same
/// frame — see the divergence register row this class's own commit files
/// for the acknowledged "sibling with a later re-raise" shape.</summary>
public void Tick()
{
if (_popupRoot is not null)
_host.BringToFront(_popupRoot);
UpdateWorldHoverTooltip();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_host.TooltipShow -= OnTooltipShow;
_host.TooltipHide -= OnTooltipHide;
RemovePopup();
}
}