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>
803 lines
41 KiB
C#
803 lines
41 KiB
C#
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(&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=<empty></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();
|
||
}
|
||
}
|