feat(ui): #409 — client-wide retail tooltip system

Full re-derivation from named-retail decomp: UIElement::StartTooltipAtMouse
@0x00460D70 -> UIElementManager::StartTooltip @0x0045DE90/@0x00459700,
UIElement::MouseHover @0x00462520 (P0x4B TooltipOn gate + global
m_tooltipEnable), UIElementManager::CheckTooltip @0x0045B6E0 (dwell/
auto-hide timer, default 0.25s/10s), SwitchMouseOver/DeletingElement
(dismissal). Corrects the earlier GF-16 investigation: P0x47 is the
element-desc id WITHIN the popup LayoutDesc (P0x48), not a "behavior
enum"; P0x4A is read off the popup's own instantiated root, not the
trigger element.

- ElementInfo/UiElement gain six tooltip data fields (P0x47/48/49/4A/4B/50),
  read generically by ElementReader and copied through LayoutImporter,
  mirroring the existing AuthoredInvisible passthrough pattern.
- UiRoot's existing CheckTooltip-derived hover timer gains TooltipShow/
  TooltipHide events, a per-element P0x50 delay override, and dismissal
  wiring at every retail-confirmed teardown site.
- RetailTooltipPresenter (owned by RetailUiRuntime, mounted alongside
  RetailDialogFactory) builds the popup via the existing LayoutImporter
  dat-lock seam, auto-resizes by the measured-vs-authored text delta
  (word-wrapped via the existing UiText.WrapWords primitive), positions
  at the mouse clamped to the display, and stays topmost over dialogs via
  its own later per-tick BringToFront (register AD-106).
- Misc.TooltipEnable/Misc.TooltipDelay are client-local UserPreferences
  (retail's own 2013 Config tab authors no visible row for either) —
  SettingsStore gains a MiscSettings section, no new options-panel row.
- Live-DAT sweep: 434 elements author >=1 trigger property (243 with
  literal text this port shows; 191 rely on retail's dynamic
  InqProperty(0x49) override, deferred as register TS-85 alongside the
  unmodeled P0x3D wrap-width override).

Gates: Release build 0 errors; App suite (live-DAT env) 5410/5407 passed/
3 skipped (was 5379/3); Runtime 1735/0 unchanged; UI.Abstractions 926/0;
full solution 14,617/14,548 passed/69 skipped/0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 20:03:24 +02:00
parent d3755eb231
commit a377b9bff7
16 changed files with 1386 additions and 9 deletions

View file

@ -0,0 +1,244 @@
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 (no hotspot offset),
/// 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. 434 installed
/// elements author at least one of the five properties (243 with literal
/// <c>P0x49</c> text this port can show; the other 191 rely on retail's DYNAMIC
/// <c>InqProperty(0x49)</c> override, out of this port's scope — see the
/// divergence register). 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 it as a
/// <c>UserPreferences.ini</c> key via <c>UIPreferences::AttachPreference</c>,
/// 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;
private void OnTooltipShow(UiElement widget)
{
RemovePopup();
if (!Enabled || !widget.AuthoredTooltipEnabled)
return;
if (string.IsNullOrEmpty(widget.AuthoredTooltipText))
return;
if (widget.AuthoredTooltipLayoutDid == 0u || widget.AuthoredTooltipRootElementId == 0u)
return;
ImportedLayout? layout;
try
{
layout = _createLayout(widget.AuthoredTooltipLayoutDid, widget.AuthoredTooltipRootElementId);
}
catch (Exception error)
{
Console.WriteLine(
$"[UI] #409 tooltip popup layout=0x{widget.AuthoredTooltipLayoutDid:X8} "
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
return;
}
if (layout is null)
return;
UiElement root = layout.Root;
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
? layout.FindElement(root.AuthoredTooltipTextChildElementId)
: null;
if (textChild is UiText text)
ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
SetClickThroughRecursive(root);
PositionAtMouse(root);
_host.AddChild(root);
_host.BringToFront(root);
_popupRoot = root;
_owner = widget;
}
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;
}
/// <summary>Force-hides whatever tooltip is currently showing, if any.
/// Session-reset callers use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s
/// own role for dialogs) so a stale popup cannot survive a reconnect.</summary>
public void HideCurrent() => RemovePopup();
/// <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.</summary>
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
{
float authoredTextWidth = text.Width;
float authoredTextHeight = text.Height;
Func<string, float> measure = text.DatFont is { } datFont
? datFont.MeasureWidth
: text.Font is { } bitmapFont
? bitmapFont.MeasureWidth
: static s => s.Length * 8f;
float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X);
var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth);
text.LinesProvider = () => wrapped
.Select(line => new UiText.Line(line, text.DefaultColor))
.ToArray();
float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure);
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
float measuredHeight = wrapped.Count * lineHeight;
root.Width += measuredWidth - authoredTextWidth;
root.Height += measuredHeight - authoredTextHeight;
text.Width = measuredWidth;
text.Height = measuredHeight;
}
/// <summary>Retail <c>UIElementManager::StartTooltip @0x00459700</c>: the
/// popup's top-left lands EXACTLY at the mouse cursor (no hotspot offset),
/// clamped so it never crosses the right/bottom display edge (nor goes
/// negative, mirroring retail's own <c>max(0, ...)</c> defensive clamp).</summary>
private void PositionAtMouse(UiElement root)
{
var canvas = _host.EffectiveCanvasSize;
float x = Math.Clamp(_host.MouseX, 0, MathF.Max(0f, canvas.X - root.Width));
float y = Math.Clamp(_host.MouseY, 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);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_host.TooltipShow -= OnTooltipShow;
_host.TooltipHide -= OnTooltipHide;
RemovePopup();
}
}