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

@ -266,6 +266,95 @@ public sealed class ElementInfo
/// </summary>
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
/// <summary>
/// #409 (client-wide retail tooltip system): whether THIS element is
/// eligible to trigger retail's hover tooltip at all — dat property
/// <c>0x4B</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case <c>0x18</c>
/// (<c>GetPropertyName()-0x33==0x18</c>, i.e. property id
/// <c>0x33+0x18=0x4B</c>) calls <c>UIRegion::SetTooltipOn</c>, which
/// toggles bit 5 of <c>__bitfield164</c>; <c>UIElement::MouseHover
/// @0x00462520</c> checks exactly that bit (plus the GLOBAL
/// <c>UIElementManager::m_tooltipEnable</c> preference) before calling
/// <c>StartTooltipAtMouse</c>. Default false — the ctor-cleared bit,
/// matching "an element must explicitly author <c>P0x4B=true</c> to
/// ever show a tooltip."
/// </summary>
public bool TooltipEnabled;
/// <summary>
/// #409: the tooltip's TEXT, dat property <c>0x49</c>
/// (<c>StringInfo</c>). Retail <c>UIElement::StartTooltipAtMouse
/// @0x00460D70</c> prefers the element's own runtime-settable
/// <c>m_TTText</c> field (set only by the separate, non-dat-authored
/// <c>UIElement::SetTooltip</c> API — game code showing dynamic
/// status text, out of this property's scope) and falls back to
/// <c>vtable-&gt;InqProperty(0x49, ...)</c> — the generic dat-authored
/// property bag read — whenever <c>m_TTText</c> is empty, which for a
/// pure DAT-imported element is ALWAYS (nothing in
/// <c>OnSetAttribute</c>'s switch populates <c>m_TTText</c> from a dat
/// property). Kept RAW (unresolved <see cref="UiStringInfoValue"/>) at
/// this layer — <see cref="ElementReader"/> is dat-free/string-free by
/// design; resolution through <c>DatStringResolver</c> happens in
/// <c>DatWidgetFactory</c>, mirroring the existing <c>0x17</c> caption
/// pattern exactly.
/// </summary>
public UiStringInfoValue? TooltipText;
/// <summary>
/// #409: the ROOT ELEMENT ID to instantiate WITHIN the tooltip popup
/// LayoutDesc (<see cref="TooltipLayoutDid"/>) — dat property
/// <c>0x47</c> (<c>EnumBaseProperty</c>, read via
/// <c>UIElement::GetAttribute_Enum(this, 0x47, ...)</c>). Retail
/// <c>UIElementManager::StartTooltip @0x0045DE90</c> passes this value
/// straight to <c>LayoutDesc::AccessElementDesc</c> — it is an
/// element-desc token WITHIN the popup layout, NOT a "tooltip behavior
/// enum" (an earlier, less complete investigation guessed the latter;
/// full decomp re-derivation for this port corrects it). Zero when the
/// element authors no tooltip.
/// </summary>
public uint TooltipRootElementId;
/// <summary>
/// #409: the tooltip POPUP LayoutDesc DID — dat property <c>0x48</c>
/// (<c>DataIdBaseProperty</c>, read via
/// <c>UIElement::GetAttribute_DataID(this, 0x48, ...)</c>). Retail
/// authors <c>0x21000041</c> here on every tooltip-bearing element
/// (live-DAT-probe-confirmed by the #409 investigation). Retail falls
/// back to the HOVERING element's OWN containing <c>m_layout</c> DID
/// when <c>P0x48</c> is absent but <c>P0x47</c> is present — an edge
/// case this port does not reproduce (every live-DAT element the #409
/// sweep found authors <c>P0x48</c> directly alongside <c>P0x47</c>;
/// see the divergence register for the honest row). Zero when absent.
/// </summary>
public uint TooltipLayoutDid;
/// <summary>
/// #409: the tooltip TEXT CHILD's element id — dat property
/// <c>0x4A</c> (<c>EnumBaseProperty</c>). Unlike the other five
/// tooltip properties, retail reads THIS one off the freshly
/// INSTANTIATED popup's own ROOT element (<c>UIElement::
/// GetAttribute_Enum(tooltipRootElement, 0x4a, &amp;textChildId)</c>
/// inside <c>UIElementManager::StartTooltip @0x0045DE90</c>'s
/// fallback branch), not off the hovering trigger element — so this
/// field is meaningful only when read from the popup layout's OWN
/// resolved root <see cref="ElementInfo"/>/<c>UiElement</c>, which the
/// same generic property-read machinery populates uniformly for every
/// imported element regardless of role.
/// </summary>
public uint TooltipTextChildElementId;
/// <summary>
/// #409: a per-element hover-dwell delay OVERRIDE in seconds — dat
/// property <c>0x50</c> (<c>FloatBaseProperty</c>). Retail
/// <c>UIElementManager::CheckTooltip @0x0045B6E0</c> reads
/// <c>UIElement::GetAttribute_Float(hoveredElement, 0x50, &amp;delay)</c>
/// and uses it in place of the global <c>m_tooltipDelay</c>
/// (<c>Misc.TooltipDelay</c> preference, default 0.25 s) when present.
/// Null when the element authors no override.
/// </summary>
public float? TooltipDelaySeconds;
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
@ -603,6 +692,32 @@ public static class ElementReader
{
info.Invisible = invisible;
}
// #409 (client-wide retail tooltip system): the six per-element
// tooltip properties. See each ElementInfo field's own doc comment
// for the exact decomp anchor. TooltipTextChildElementId (0x4A) is
// read the SAME generic way here even though retail only consults
// it off a tooltip POPUP's own root element — the read is uniform
// and harmless for every other element (it stays 0).
if (info.TryGetEffectiveBool(0x4Bu, out bool tooltipOn))
{
info.TooltipEnabled = tooltipOn;
}
if (info.TryGetEffectiveProperty(0x49u, out var tooltipText)
&& tooltipText.Kind == UiPropertyKind.StringInfo)
{
info.TooltipText = tooltipText.StringInfoValue;
}
info.TooltipRootElementId = ReadReferencedElementId(info, 0x47u);
info.TooltipLayoutDid = ReadReferencedElementId(info, 0x48u);
info.TooltipTextChildElementId = ReadReferencedElementId(info, 0x4Au);
if (info.TryGetEffectiveFloat(0x50u, out float tooltipDelay))
{
info.TooltipDelaySeconds = tooltipDelay;
}
}
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)