using System;
using System.Linq;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
///
/// #409 — client-wide retail hover-tooltip system. Consumes 's
/// existing hover-dwell timer (/
/// ) 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.
///
///
/// Retail mechanism (decomp anchors — see also each ElementInfo.Tooltip*
/// field's own doc comment for the individual property citations):
///
/// - UIElementManager::CheckTooltip @0x0045B6E0 — the
/// per-frame dwell/auto-hide timer, ported into .
/// - UIElement::MouseHover @0x00462520 — the per-element
/// gate (P0x4B TooltipOn bit + the global m_tooltipEnable
/// preference); ported as this class's +
/// check.
/// - UIElement::StartTooltipAtMouse @0x00460D70 — text
/// resolution (P0x49) and dispatch to the manager.
/// - UIElementManager::StartTooltip @0x0045DE90 —
/// instantiates the popup (P0x47 root element id within P0x48's
/// LayoutDesc), resolves the text child (P0x4A, read off the POPUP's own
/// root), sets its text, and auto-resizes the root by the measured-vs-authored
/// text delta.
/// - UIElementManager::StartTooltip @0x00459700 —
/// positions the popup's top-left at the mouse cursor OFFSET by 32px on
/// BOTH axes (@0x00459739/@0x00459747), clamped to the
/// display.
///
///
///
///
/// Live-DAT-probed structure (#409 investigation): layout 0x21000041's
/// root (id 0, 800x600, a pure catalog container) holds four 30x30 popup skins
/// (0x10000487/0x10000395/0x10000397/0x10000398),
/// each a four-piece bevel frame around one Type-12 text child
/// 0x10000396 — every one of those four roots' own P0x4A resolves
/// to exactly 0x10000396, confirming the decomp reading. 430 installed
/// elements author at least one of the five properties (243 with literal
/// P0x49 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 InqProperty(0x49) 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 m_TTText/SetTooltip family
/// headed by the P0xD0 truncated-text auto-tooltip). A fifth root id
/// (0x100001F0) and a second popup layout (0x21000026) 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.
///
///
public sealed class RetailTooltipPresenter : IDisposable
{
private readonly UiRoot _host;
/// Resolves a tooltip popup's LayoutDesc + root element (the
/// widget's own /
/// ) to a mounted-ready
/// . Wraps LayoutImporter.Import with the
/// runtime's dat lock/resolve/font context — the same shape
/// RetailUiRuntime.CreateLayout already gives .
private readonly Func _createLayout;
private UiElement? _popupRoot;
private UiElement? _owner;
private bool _disposed;
public RetailTooltipPresenter(UiRoot host, Func createLayout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
_host.TooltipShow += OnTooltipShow;
_host.TooltipHide += OnTooltipHide;
}
///
/// The client-side Misc.TooltipEnable preference
/// (UIElementManager::Init @0x0045EE10 registers the LIVE binding
/// via UserPreferences::RegisterPreference @0x0045EE92 — see
/// 's
/// own doc comment for the full two-mechanism citation split —
/// default true — m_tooltipEnable=1 @0x0045F756). NOT part of the
/// server-synced CharacterOptionTable — 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
/// RuntimeCharacterOptionsState. Gates ONLY the popup presentation —
/// matching retail's own gate point at UIElement::MouseHover — NOT
/// 's dwell timer, which keeps running either way exactly
/// as retail's CheckTooltip does.
///
public bool Enabled { get; set; } = true;
///
/// Retail UIElement::StartTooltipAtMouse @0x00460D70's text source, in
/// retail's own order: the RUNTIME m_TTText first
/// (@0x00460DA3 tests StringInfo::IsValid(&m_TTText) and takes
/// it verbatim at @0x00460DAA), and only when that is empty does it fall
/// back to the AUTHORED P0x49 property (@0x00460DDF
/// InqProperty(0x49)).
///
///
/// UiElement.GetTooltipText() is this port's m_TTText: it is what
/// every acdream analog of retail's ~15 game-code UIElement::SetTooltip
/// call sites already writes — the Options panel's per-row
/// ID_PlayerOption_*_Help strings
/// (UIOption_CheckboxBitfield64::CreateChildren @0x00485E65's
/// siTooltip array; acdream CharacterOptionsPageController,
/// ChatOptionsPageController, ConfigOptionsPageController,
/// UiCheckboxBitfield64), the Configure-Keyboard key captions, and the
/// social pages' checkbox help. Reading only
/// (as this class did before) is why NONE of those showed live: retail's
/// in-world panels author the popup LOCATOR (P0x47/P0x48) and the
/// P0x4B on-bit but deliberately author NO P0x49 text, because the
/// text arrives at runtime. Live-DAT measured: the Options toggle row
/// (0x2100002B/0x10000218) authors
/// P0x47=0x10000397 P0x48=0x21000041 P0x4B=true P0x49=<empty>.
///
///
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;
}
///
/// Shared popup-build/mount body for BOTH tooltip triggers this class
/// owns: the UI-element hover-dwell path above (,
/// per-widget authored P0x47/P0x48) and the world-object
/// hover path below (, the fixed
/// popup-skin pair every game-code SetTooltip caller in this
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
/// OnTooltipShow body — same F4/F5/F8 fixes, same failure
/// handling.
///
///
/// Night-round review F5: the single-popup invariant (retail's own
/// single m_pTooltipElement 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: 's
/// own clear is gated on _worldTooltipShowing (only true when the
/// WORLD path itself mounted the current popup) and its "a UI popup
/// cannot be showing here" comment assumed '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 stays mounted
/// (_owner/_popupRoot set, _worldTooltipShowing
/// 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.
///
///
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.
///
/// 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 (ItemListCellTemplate.CatalogLayoutId,
/// LayoutDesc 0x21000041) authors the SAME
/// P0x47=0x10000395/P0x48=0x21000041 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 —
/// TooltipLiveDatTests.PopupSkinRootIds/
/// UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator) —
/// one of the four 30x30 popup skins this presenter mounts for every
/// authored tooltip-bearing element too.
///
///
/// 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 UiItemSlot's item-cell popup.
/// MapPageController's town markers were REMOVED from the list at
/// the 2026-08-17 morning gate round (finding 3): the hotspot template
/// (0x100001F0 in 0x21000026) turned out to author its OWN
/// popup locator (P0x47=0x10000398/P0x48=0x21000041 — the
/// fourth skin, whose incorporated text child fonts 0x40000015
/// where the other three font 0x40000002), 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 (OnTooltipShow/UpdateWorldHoverTooltip both call
/// TryBuildAndMountPopup with these values or a widget's own).
///
public const uint SharedPopupSkinRootElementId = 0x10000395u;
public const uint SharedPopupSkinLayoutDid = 0x21000041u;
private uint _worldHoverGuid;
private bool _worldTooltipShowing;
/// The wrapper's staged m_TTText — written at the
/// found-object EDGE (SetTooltip @0x004E5D74 /
/// ClearTooltip @0x004E5E30), displayed only when the dwell
/// machinery mounts it. Null/empty = cleared (retail's empty
/// StringInfo; StartTooltipAtMouse @0x00460DA3's
/// IsValid test fails and the wrapper authors no P0x49
/// fallback, so nothing shows).
private string? _worldStagedText;
/// When the world popup mounted — retail
/// m_tooltipStart, for the m_tooltipDuration (10 s)
/// auto-hide (CheckTooltip @0x0045b78a).
private long _worldTooltipShownMs;
/// Set by the duration auto-hide: retail's expiry path calls
/// SwitchMouseOver(this, nullptr) @0x0045b7b2, clearing
/// m_pElementLastEntered — 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.
private bool _worldRearmRequiresMouseMove;
private int _worldLastSeenMouseX = int.MinValue;
private int _worldLastSeenMouseY = int.MinValue;
///
/// The world-hover pick (retail's SmartBox::find_object via
/// UIElement_SmartBoxWrapper::FindObject @0x004E5430's fallback
/// branch) — a per-frame "what's under the cursor right now" query,
/// distinct from click-driven SelectionState. Queried only when
/// finds no UI element under the cursor
/// (mirrors FindObject's m_pElementLastOver 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).
///
public Func? WorldHoverGuidProvider { get; set; }
///
/// ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) —
/// 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 @0x004E5D48).
///
public Func? WorldHoverNameResolver { get; set; }
///
/// PlayerModule::ShowTooltips (CharacterOptionId.ShowTooltips) —
/// read once per found-object edge, exactly where retail reads it
/// (@0x004E5D21). 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.
///
public Func? 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;
}
}
/// Force-hides whatever tooltip is currently showing, if any,
/// and forgets the staged world-hover text/guid. Session-reset callers
/// use this (mirrors '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.
public void HideCurrent()
{
RemovePopup();
_worldHoverGuid = 0u;
_worldStagedText = null;
_worldRearmRequiresMouseMove = false;
}
/// Retail UIElementManager::StartTooltip @0x0045DE90's text
/// + auto-resize step. Wraps at the display width (retail's
/// UIElement_Text::InqSizewMargins(..., UITS_MAX_WIDTH) falls back to
/// RenderDevice::GetDisplayWidth() when the text element authors no
/// P0x3D 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.
///
///
/// F8 additions (2026-08-16 review round): (1) InqSizewMargins
/// returns a size with the text element's own margins already added
/// back in (@0x004697a4/@0x004697bc: += m_margR +
/// m_margL / += m_margD + m_margU) before StartTooltip
/// diffs it against the AUTHORED width/height — zeroing
/// here (same shape the sibling
/// RetailMessageDialogView uses for its own message child,
/// RetailMessageDialogView.cs:53) 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 UIElement::ResizeTo @0x00463C30
/// applies to every resize (P0x3C max-height/P0x3E
/// min-height/P0x3D max-width/P0x3F 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.
///
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
{
float authoredTextWidth = text.Width;
float authoredTextHeight = text.Height;
text.Padding = 0f;
Func 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;
}
/// Retail UIElementManager::StartTooltip @0x00459700: the
/// popup's top-left lands at the mouse cursor OFFSET by
/// on BOTH axes (@0x00459739: mouseX + 0x20; @0x00459747:
/// mouseY + 0x20), clamped so it never crosses the right/bottom
/// display edge (nor goes negative, mirroring retail's own
/// max(0, ...) defensive clamp, @0x00459753/@0x00459769).
/// Retail's own clamp ORDER (max-vs-display computed against the raw
/// offset mouse position, not the already-floored one — @0x00459784-
/// @0x0045979c) can go negative when the popup is bigger than the
/// display; cannot express that (its min
/// must be max) — immaterial for every popup this port
/// builds (fixed 30x30 skins plus a bounded auto-grow), left unmatched.
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;
}
/// 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).
/// and already default ClickThrough=true, but
/// this walk makes the guarantee unconditional across whatever the popup
/// LayoutDesc happens to be authored with.
private static void SetClickThroughRecursive(UiElement element)
{
element.ClickThrough = true;
foreach (UiElement child in element.Children)
SetClickThroughRecursive(child);
}
/// Re-asserts the popup's z-order above whatever
/// raised this frame — mirrors that
/// factory's own per-tick BringToFront 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 ticks both
/// and 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.
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();
}
}