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

@ -107,8 +107,75 @@ horizontal `HJustify` mapping while in this code, since it shares the
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
**Status:** OPEN
**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for ~253 authored elements client-wide and acdream shows none)
**Status:** CODE-COMPLETE 2026-08-16 (this commit) — pending the user's connected visual gate (see the gate note at the bottom of this entry).
**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix)
**2026-08-16 re-derivation + port (this commit).** Full re-derivation from
`docs/research/named-retail/acclient_2013_pseudo_c.txt` corrected two things
the original GF-16 filing below got wrong from a shallower pass: **`P0x47`
is NOT a "tooltip behavior enum" — it is the element-desc id WITHIN the
popup LayoutDesc (`P0x48`) to instantiate as the popup's root**
(`UIElementManager::StartTooltip @0x0045DE90` passes it straight to
`LayoutDesc::AccessElementDesc`), and **`P0x4A` is read off the freshly
INSTANTIATED popup's own root element, not the hovering trigger element**
(it names that popup's text-child id). A live-DAT sweep (installed EoR
build) found **434 elements author at least one of the five trigger
properties** (243 with literal `P0x49` `StringInfo` text this port shows;
the other 191 rely on retail's DYNAMIC `InqProperty(0x49)` text override,
out of scope — see register row TS-85), superseding the original "~253"
estimate. Layout `0x21000041` holds four 30x30 popup skins
(`0x10000487`/`0x10000395`/`0x10000397`/`0x10000398`), each a four-piece
bevel frame around one shared Type-12 text child `0x10000396` — confirmed
by `TooltipLiveDatTests`.
**Shipped:** the six-property data layer (`ElementInfo`/`UiElement`
`Tooltip*`/`AuthoredTooltip*` fields, `ElementReader`,
`DatWidgetFactory.ResolveTooltipText`, `LayoutImporter.BuildWidget`); the
hover-dwell/auto-hide/dismissal state machine (`UiRoot.Tick`'s existing
`CheckTooltip` port gained `TooltipShow`/`TooltipHide` C# events, a
per-element `P0x50` delay-override consult, and dismissal wiring at every
retail-confirmed teardown site — hover-target change, owner-element
removal, duration timeout); `RetailTooltipPresenter` (owned by
`RetailUiRuntime`, mounted alongside `RetailDialogFactory`) — builds the
popup via the SAME `LayoutImporter`/dat-lock seam dialogs use, auto-resizes
by the retail measured-vs-authored-text delta (word-wrapped at the display
width via the existing `UiText.WrapWords` primitive), positions at the
mouse cursor clamped to the display, keeps itself topmost over dialogs via
its own later per-tick `BringToFront` (register AD-106), and gates on both
the global `Misc.TooltipEnable` preference (client-local, `SettingsStore`'s
new `MiscSettings` section — retail's OWN 2013 Config tab authors no
visible row for it either, confirmed by the OP campaign's own research, so
no new options-panel row was added) and the widget's own `P0x4B`. **No
click-dismissal was ported** — `UIElementManager::MouseDownEvent
@0x0045DB60` calls the SAME `SwitchMouseOver` hover-change check that
already drives dismissal, and it no-ops when the hit-tested element hasn't
changed, so retail itself does not dismiss a tooltip by clicking its own
owner.
**Deferred (register TS-85):** dynamic per-element tooltip text
(`InqProperty(0x49)`, 191 elements) and the `P0x3D` per-element wrap-width
override (zero elements author one today). **Deferred (register AD-106):**
the topmost-z-order mechanism is a sibling-with-later-reraise adaptation,
not retail's literal separate presentation layer.
**Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`):** hover the mouse over any
of these and hold still — a small tooltip box should appear after a brief
pause (~0.25 s, matching retail's registered default) and disappear when
you move to a different control: (1) chargen Appearance page — the rotate
arrows beside the preview ("Rotate left."/"Rotate right.") and any color
swatch ("Changes color of selected clothing or body part."); (2) the same
page's hair/eyes/nose spin arrows (longer help text — should WRAP across
multiple lines, not run off-screen); (3) any other screen with a tooltip
you recall from retail. Confirm: the box sits right at the cursor (not
offset), never runs off the edge of the window even near a corner, and
disappears on its own after ~10 s if you hold still without moving away.
No click-to-dismiss is expected — only moving off the control, or a very
long hold, closes it.
---
**Original GF-16 filing (superseded by the re-derivation above; kept for
investigation history).**
Found during Campaign CC gate round 1's Batch D root-cause investigation
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16

File diff suppressed because one or more lines are too long

View file

@ -1183,4 +1183,21 @@ public static class DatWidgetFactory
/// </summary>
private static string? NormalizeEscapes(string? raw) =>
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
/// <summary>
/// #409 (client-wide retail tooltip system): resolves the already-
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
/// SAME escape normalization every other authored <c>StringInfo</c>
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
/// element authors no tooltip text or no resolver is available.
/// </summary>
internal static string? ResolveTooltipText(
ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve)
{
if (stringResolve is null || info.TooltipText is not { } tooltipText)
return null;
return NormalizeEscapes(stringResolve(tooltipText));
}
}

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)

View file

@ -121,6 +121,18 @@ public static class LayoutImporter
// doc comment for why this does NOT set Visible here.
w.AuthoredInvisible = info.Invisible;
// #409: the six per-element tooltip properties, same pure-data-
// passthrough shape as AuthoredInvisible above. TooltipText is the
// one property resolved to a display string here (mirrors every
// other authored StringInfo caption in this file); the rest stay
// raw ids/flags for RetailTooltipPresenter to act on.
w.AuthoredTooltipEnabled = info.TooltipEnabled;
w.AuthoredTooltipText = DatWidgetFactory.ResolveTooltipText(info, stringResolve);
w.AuthoredTooltipRootElementId = info.TooltipRootElementId;
w.AuthoredTooltipLayoutDid = info.TooltipLayoutDid;
w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId;
w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds;
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat

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();
}
}

View file

@ -528,6 +528,7 @@ public sealed class RetailUiRuntime : IDisposable
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
MountTooltipPresenter();
MountSocialPanel();
MountCharacter();
MountPlugins();
@ -630,6 +631,9 @@ public sealed class RetailUiRuntime : IDisposable
public UiNineSlicePanel? InventoryFrame { get; private set; }
public InventoryController? InventoryPanelController { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
/// <summary>#409: client-wide retail hover-tooltip presenter. Constructed
/// alongside <see cref="DialogFactory"/> — see <c>ConfigureDialogFactory</c>.</summary>
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
@ -813,6 +817,11 @@ public sealed class RetailUiRuntime : IDisposable
CharacterCreationController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
// #409: MUST tick after both DialogFactory and Host so the tooltip's
// own BringToFront re-raise always lands on top of whatever z-order
// those two just asserted this frame — see RetailTooltipPresenter.Tick's
// own doc comment and the register row filed alongside this class.
TooltipPresenter?.Tick();
_automation?.Tick(deltaSeconds);
}
@ -979,6 +988,7 @@ public sealed class RetailUiRuntime : IDisposable
{
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
}
finally
{
@ -3314,6 +3324,47 @@ public sealed class RetailUiRuntime : IDisposable
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
/// <summary>
/// #409: mounts the client-wide retail tooltip presenter. Unlike
/// <see cref="MountDialogFactory"/>'s catalog resolve, the tooltip popup
/// layout/root are entirely data-driven off each hovered widget's OWN
/// authored <c>0x47</c>/<c>0x48</c> properties (<see cref="RetailTooltipPresenter"/>'s
/// own class doc) — this method only wires the generic layout-by-DID
/// resolver and seeds the client-local <c>Misc.TooltipEnable</c>/
/// <c>Misc.TooltipDelay</c> preferences from the shared
/// <see cref="SettingsStore"/> (the same instance
/// <c>_bindings.Chat.Store</c> already threads to every other client-local
/// preference reader in this file).
/// </summary>
private void MountTooltipPresenter()
{
if (TooltipPresenter is not null)
return;
ImportedLayout? CreateTooltipLayout(uint layoutDid, uint rootElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
layoutDid,
rootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
}
TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout);
if (_bindings.Chat.Store is { } store)
{
MiscSettings misc = store.LoadMisc();
TooltipPresenter.Enabled = misc.TooltipEnable;
Host.Root.TooltipDelayMs = (int)(misc.TooltipDelaySeconds * 1000f);
}
}
private UiShortcutDigitGraphics LoadShortcutDigitGraphics()
{
if (_shortcutDigitGraphics is not null)
@ -4194,7 +4245,11 @@ public sealed class RetailUiRuntime : IDisposable
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(),
() =>
{
DialogFactory?.Dispose();
TooltipPresenter?.Dispose();
},
_panelUi.Dispose,
Host.Dispose);
_shutdown.CompleteOrThrow();

View file

@ -70,6 +70,60 @@ public abstract class UiElement
/// </summary>
public bool AuthoredInvisible { get; internal set; }
/// <summary>
/// #409 (client-wide retail tooltip system): mirrors
/// <c>ElementInfo.TooltipEnabled</c> (dat property <c>0x4B</c>) — a
/// PURE DATA PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c>.
/// Gates whether <see cref="RetailTooltipPresenter"/> may show a
/// tooltip for this widget at all (retail's per-element
/// <c>UIRegion::SetTooltipOn</c> bit, checked by
/// <c>UIElement::MouseHover @0x00462520</c> alongside the global
/// enable preference). See <see cref="AcDream.App.UI.Layout.ElementInfo.TooltipEnabled"/>.
/// </summary>
public bool AuthoredTooltipEnabled { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipText</c> (dat property
/// <c>0x49</c>), already resolved to a display string through
/// <c>DatStringResolver</c> and escape-normalized at import time —
/// the same treatment every other authored <c>StringInfo</c> caption
/// gets. Null when the element authors no tooltip text.
/// </summary>
public string? AuthoredTooltipText { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipRootElementId</c> (dat property
/// <c>0x47</c>) — the element-desc id WITHIN
/// <see cref="AuthoredTooltipLayoutDid"/>'s LayoutDesc to instantiate
/// as the tooltip popup's root. Zero when absent.
/// </summary>
public uint AuthoredTooltipRootElementId { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipLayoutDid</c> (dat property
/// <c>0x48</c>) — the tooltip popup LayoutDesc DID (retail authors
/// <c>0x21000041</c> on every tooltip-bearing element). Zero when
/// absent.
/// </summary>
public uint AuthoredTooltipLayoutDid { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
/// property <c>0x4A</c>). Meaningful only when read off a tooltip
/// POPUP's own instantiated ROOT widget — see the
/// <c>ElementInfo</c> field's own doc for why retail reads this off
/// the popup, not the hovering trigger element.
/// </summary>
public uint AuthoredTooltipTextChildElementId { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipDelaySeconds</c> (dat property
/// <c>0x50</c>) — a per-element hover-dwell override in seconds, used
/// by <see cref="UiRoot"/>'s tooltip timer in place of the global
/// <see cref="UiRoot.TooltipDelayMs"/> when present. Null = no override.
/// </summary>
public float? AuthoredTooltipDelaySeconds { get; internal set; }
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>

View file

@ -330,11 +330,51 @@ public sealed class UiRoot : UiElement
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
/// <summary>Global hover-dwell delay in ms before a tooltip fires — retail
/// <c>UIElementManager::m_tooltipDelay</c>, default 0.25 s
/// (<c>UIElementManager::UIElementManager @0x0045F5D0</c>), the
/// <c>Misc.TooltipDelay</c> preference. A hovered widget's own
/// <see cref="UiElement.AuthoredTooltipDelaySeconds"/> (dat property
/// <c>0x50</c>) overrides this per <c>UIElementManager::CheckTooltip
/// @0x0045B6E0</c> — see <see cref="EffectiveTooltipDelayMs"/>.</summary>
public int TooltipDelayMs { get; set; } = 250;
/// <summary>How long a shown tooltip stays up before auto-hiding — retail
/// <c>m_tooltipDuration</c>, a fixed 10 s
/// (<c>UIElementManager::CheckTooltip @0x0045B6E0</c>'s own auto-hide
/// check); not a user preference.</summary>
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
/// <summary>
/// #409: fired once, synchronously, when a hovered widget's dwell delay
/// elapses (retail's <c>UIElementManager::StartHover</c> ->
/// <c>UIElement::MouseHover</c> edge). <see cref="RetailTooltipPresenter"/>
/// is the production consumer — it decides whether the widget actually
/// authors a tooltip and, if so, builds/positions the popup.
/// </summary>
public event Action<UiElement>? TooltipShow;
/// <summary>
/// #409: fired when a previously-shown tooltip must go away — hover
/// left the widget (<see cref="UpdateHover"/>), the widget's subtree is
/// being removed (<see cref="ClearSubtreeOwnership"/>), or the shown
/// duration elapsed (<see cref="Tick"/>'s auto-hide branch). Only fires
/// if <see cref="TooltipShow"/> actually fired for this widget first
/// (mirrors retail's own <c>m_pTooltipElement != null</c> guard at every
/// one of those three call sites).
/// </summary>
public event Action<UiElement>? TooltipHide;
/// <summary>Retail <c>UIElementManager::CheckTooltip @0x0045B6E0</c>: a
/// per-element FLOAT delay override (dat property <c>0x50</c>) replaces
/// the global <see cref="TooltipDelayMs"/> when the hovered element
/// authors one.</summary>
private int EffectiveTooltipDelayMs(UiElement widget)
=> widget.AuthoredTooltipDelaySeconds is { } seconds
? (int)(seconds * 1000f)
: TooltipDelayMs;
private long _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary>
@ -440,6 +480,12 @@ public sealed class UiRoot : UiElement
{
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
// #409: retail UIElementManager::DeletingElement @0x0045E520 tears
// down the active tooltip when its owner element is removed
// (m_pTooltipOwner == ebp). Only fire if a tooltip actually
// showed for this widget (mirrors that null-guarded check).
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
_hoverWidget = null;
_tooltipFired = false;
}
@ -475,21 +521,23 @@ public sealed class UiRoot : UiElement
{
_nowMs = nowMs;
// Tooltip timer: once mouse has hovered over the same widget for
// TooltipDelayMs, fire a Tooltip event on it exactly once.
// Tooltip timer: once mouse has hovered over the same widget for its
// effective dwell delay, fire a Tooltip event on it exactly once.
if (_hoverWidget is not null && !_tooltipFired
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
&& _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget))
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
TooltipShow?.Invoke(_hoverWidget);
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
TooltipHide?.Invoke(_hoverWidget);
_hoverWidget = null;
_tooltipFired = false;
}
@ -1241,6 +1289,16 @@ public sealed class UiRoot : UiElement
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
// #409: retail UIElementManager::SwitchMouseOver @0x0045B560 calls
// StopHover (which tears down m_pTooltipElement) the instant the
// hovered element changes — the ONLY confirmed dismissal trigger
// besides duration-timeout and element-removal. Notably, retail's
// MouseDownEvent @0x0045DB60 calls SwitchMouseOver with the SAME
// hit-tested element, so clicking the tooltip's own owner does
// NOT dismiss it (SwitchMouseOver no-ops when the target hasn't
// changed) — no click-dismissal is ported here for that reason.
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;

View file

@ -0,0 +1,33 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// #409 (client-wide retail tooltip system): the two <c>Misc.*</c>
/// <c>UserPreferences.ini</c> keys retail's <c>UIElementManager::Init
/// @0x0045EE10</c> registers (<c>UIPreferences::AttachPreference</c>,
/// <c>Misc_TooltipDelay</c>/<c>Misc_TooltipEnable</c>) and binds onto
/// <c>UIElementManager::m_tooltipDelay</c>/<c>m_tooltipEnable</c>. These are
/// CLIENT-LOCAL preferences, not part of the server-synced
/// <c>CharacterOptionTable</c> — the #409 investigation confirmed retail's
/// own 2013 Config tab does not expose a visible row for either (they exist
/// in <c>UserPreferences.ini</c> and are registered engine-side, but
/// <c>gmConfigUI::InitOptions</c>'s 27 authored rows never author one), so
/// this record follows the <see cref="CameraTurningSettings"/>/
/// <see cref="AudioSettings"/> client-local persistence precedent WITHOUT a
/// matching options-panel row — persisted, but not (yet) user-editable
/// in-client, exactly matching what retail itself ships.
/// </summary>
public sealed record MiscSettings(
bool TooltipEnable,
float TooltipDelaySeconds)
{
/// <summary>
/// Retail's registered defaults: <c>m_tooltipEnable=1</c>,
/// <c>m_tooltipDelay=0.25f</c>
/// (<c>UIElementManager::UIElementManager @0x0045F5D0</c>). The
/// preference's registered clamp range is <c>[0,10]</c> seconds
/// (<c>UIPreferences::SetPreferenceRange(&amp;Misc_TooltipDelay, 0f, 10f)</c>).
/// </summary>
public static MiscSettings Default { get; } = new(
TooltipEnable: true,
TooltipDelaySeconds: 0.25f);
}

View file

@ -12,7 +12,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// <summary>
/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
/// Character; the OP9-retired <c>Gameplay</c> section is no longer read or
/// CameraTurning / Character / Misc; the OP9-retired <c>Gameplay</c> section is no longer read or
/// written — see the class's Schema note below for how a leftover
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
/// graphical host supplies a canonical portable configuration path. Coexists
@ -280,6 +280,38 @@ public sealed class SettingsStore
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
=> SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning));
/// <summary>
/// #409: load the two <c>Misc.*</c> tooltip preferences. Same fall-back
/// behaviour as <see cref="LoadDisplay"/>.
/// </summary>
public MiscSettings LoadMisc()
{
if (!File.Exists(_path)) return MiscSettings.Default;
try
{
using var stream = File.OpenRead(_path);
var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
if (!root.TryGetProperty("misc", out var misc)
|| misc.ValueKind != JsonValueKind.Object)
return MiscSettings.Default;
var d = MiscSettings.Default;
return new MiscSettings(
TooltipEnable: ReadBool (misc, "tooltipEnable", d.TooltipEnable),
TooltipDelaySeconds: ReadFloat(misc, "tooltipDelaySeconds", d.TooltipDelaySeconds));
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
return MiscSettings.Default;
}
}
/// <summary>Save the tooltip preferences, preserving all other top-level keys.</summary>
public void SaveMisc(MiscSettings misc)
=> SaveSection("misc", BuildMiscObject(misc));
/// <summary>
/// Load per-character settings keyed by <paramref name="toonKey"/>.
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
@ -652,6 +684,13 @@ public sealed class SettingsStore
["vsync"] = d.VSync,
};
private static SortedDictionary<string, object> BuildMiscObject(MiscSettings m)
=> new(StringComparer.Ordinal)
{
["tooltipDelaySeconds"] = m.TooltipDelaySeconds,
["tooltipEnable"] = m.TooltipEnable,
};
private static SortedDictionary<string, object> BuildCameraTurningObject(CameraTurningSettings c)
=> new(StringComparer.Ordinal)
{

View file

@ -478,4 +478,130 @@ public class ElementReaderTests
Assert.Equal(0u, info.ScrollbarElementId);
}
// ── #409: the six per-element tooltip property readers ─────────────────
private static UiPropertyValue DataIdProp(uint value) => new()
{
Kind = UiPropertyKind.DataId,
UnsignedValue = value,
};
private static UiPropertyValue FloatProp(float value) => new()
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
private static UiPropertyValue StringInfoProp(uint tableId, uint stringId) => new()
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, tableId, 0, 0, 0),
};
[Fact]
public void TooltipRootElementId_0x47_DecodesEnumProperty()
{
ElementInfo info = WithDirectProperty(0x47u, EnumProp(0x10000487u));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Equal(0x10000487u, info.TooltipRootElementId);
}
[Fact]
public void TooltipLayoutDid_0x48_DecodesDataIdProperty()
{
ElementInfo info = WithDirectProperty(0x48u, DataIdProp(0x21000041u));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Equal(0x21000041u, info.TooltipLayoutDid);
}
[Fact]
public void TooltipText_0x49_KeptRawAsStringInfo()
{
ElementInfo info = WithDirectProperty(0x49u, StringInfoProp(0x23000003u, 0x0AAAAAAAu));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Equal(0x23000003u, info.TooltipText!.Value.TableId);
Assert.Equal(0x0AAAAAAAu, info.TooltipText!.Value.StringId);
}
[Fact]
public void TooltipTextChildElementId_0x4A_DecodesEnumProperty()
{
ElementInfo info = WithDirectProperty(0x4Au, EnumProp(0x10000396u));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Equal(0x10000396u, info.TooltipTextChildElementId);
}
[Fact]
public void TooltipEnabled_0x4B_DecodesBoolProperty()
{
ElementInfo info = WithDirectProperty(0x4Bu, BoolProp(true));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.True(info.TooltipEnabled);
}
[Fact]
public void TooltipEnabled_0x4B_AbsentDefaultsFalse()
{
var info = new ElementInfo();
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.False(info.TooltipEnabled);
}
[Fact]
public void TooltipDelaySeconds_0x50_DecodesFloatProperty()
{
ElementInfo info = WithDirectProperty(0x50u, FloatProp(0f));
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Equal(0f, info.TooltipDelaySeconds);
}
[Fact]
public void TooltipDelaySeconds_0x50_AbsentStaysNull()
{
var info = new ElementInfo();
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.Null(info.TooltipDelaySeconds);
}
[Fact]
public void AllSixTooltipProperties_AbsentTogether_LeaveEveryFieldAtItsDefault()
{
// Regression pin: an element authoring NONE of the tooltip properties
// must never accidentally pick up a nonzero id/flag from unrelated
// property parsing.
var info = new ElementInfo();
ElementReader.ApplyCanonicalLegacyProjection(info);
Assert.False(info.TooltipEnabled);
Assert.Null(info.TooltipText);
Assert.Equal(0u, info.TooltipRootElementId);
Assert.Equal(0u, info.TooltipLayoutDid);
Assert.Equal(0u, info.TooltipTextChildElementId);
Assert.Null(info.TooltipDelaySeconds);
}
[Fact]
public void Merge_TooltipProperties_RecomputeFromTheMergedStateBag()
{
// ApplyCanonicalLegacyProjection recomputes every tooltip field fresh
// from Merge's own combined base+derived state bag (the same "no
// separate scalar-merge rule needed" shape TabTable/ScrollbarElementId
// already rely on) — a base-authored tooltip survives an unrelated
// derived override.
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId, Name = "" };
direct.Properties.Values[0x47u] = EnumProp(0x10000487u);
direct.Properties.Values[0x48u] = DataIdProp(0x21000041u);
direct.Properties.Values[0x4Bu] = BoolProp(true);
var base_ = new ElementInfo();
base_.States[UiStateInfo.DirectStateId] = direct;
ElementReader.ApplyCanonicalLegacyProjection(base_);
var derived = new ElementInfo(); // authors nothing of its own
ElementInfo merged = ElementReader.Merge(base_, derived);
Assert.Equal(0x10000487u, merged.TooltipRootElementId);
Assert.Equal(0x21000041u, merged.TooltipLayoutDid);
Assert.True(merged.TooltipEnabled);
}
}

View file

@ -298,6 +298,71 @@ public class LayoutImporterTests
Assert.Null(tree.FindElement(0xBBB00001u));
}
// ── #409: the six tooltip fields are a pure passthrough onto UiElement ──
[Fact]
public void BuildWidget_TooltipProperties_CopyOntoTheWidget_AndTextResolves()
{
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
var trigger = new ElementInfo
{
Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20,
TooltipEnabled = true,
TooltipRootElementId = 0x10000487u,
TooltipLayoutDid = 0x21000041u,
TooltipTextChildElementId = 0x10000396u,
TooltipDelaySeconds = 0.5f,
TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0),
};
string? Resolve(UiStringInfoValue info)
=> info.TableId == 0x23000003u && info.StringId == 0x0AAAAAAAu ? "Rotate left." : null;
var tree = LayoutImporter.BuildFromInfos(
root, [trigger], NoTex, null, fontResolve: null, stringResolve: Resolve);
UiElement found = tree.FindElement(0x2)!;
Assert.True(found.AuthoredTooltipEnabled);
Assert.Equal(0x10000487u, found.AuthoredTooltipRootElementId);
Assert.Equal(0x21000041u, found.AuthoredTooltipLayoutDid);
Assert.Equal(0x10000396u, found.AuthoredTooltipTextChildElementId);
Assert.Equal(0.5f, found.AuthoredTooltipDelaySeconds);
Assert.Equal("Rotate left.", found.AuthoredTooltipText);
}
[Fact]
public void BuildWidget_TooltipText_NoStringResolver_StaysNull()
{
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
var trigger = new ElementInfo
{
Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20,
TooltipText = new UiStringInfoValue(0, 0x0AAAAAAAu, 0x23000003u, 0, 0, 0),
};
var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null);
UiElement found = tree.FindElement(0x2)!;
Assert.Null(found.AuthoredTooltipText);
}
[Fact]
public void BuildWidget_NoTooltipProperties_EveryWidgetFieldStaysAtItsDefault()
{
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
var trigger = new ElementInfo { Id = 0x2, Type = 3, X = 0, Y = 0, Width = 40, Height = 20 };
var tree = LayoutImporter.BuildFromInfos(root, [trigger], NoTex, null);
UiElement found = tree.FindElement(0x2)!;
Assert.False(found.AuthoredTooltipEnabled);
Assert.Null(found.AuthoredTooltipText);
Assert.Equal(0u, found.AuthoredTooltipRootElementId);
Assert.Equal(0u, found.AuthoredTooltipLayoutDid);
Assert.Equal(0u, found.AuthoredTooltipTextChildElementId);
Assert.Null(found.AuthoredTooltipDelaySeconds);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)

View file

@ -0,0 +1,268 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// #409 (client-wide retail tooltip system) fixture coverage for
/// <see cref="RetailTooltipPresenter"/> and its <see cref="UiRoot"/> hover-
/// timer wiring. Builds synthetic popup layouts in-memory (no dats) via
/// <see cref="LayoutImporter.BuildFromInfos"/> — see
/// <see cref="TooltipLiveDatTests"/> for the installed-DAT structural pins.
/// </summary>
public sealed class RetailTooltipPresenterTests
{
/// <summary>Minimal concrete hover target — plain <see cref="UiElement"/>
/// with default (false) ClickThrough, so it is hit-testable.</summary>
private sealed class HoverTarget : UiElement;
private const uint PopupRootId = 0x900u;
private const uint TextChildId = 0x901u;
private const uint PopupLayoutDid = 0x21000041u;
/// <summary>Builds a fresh 30x30/26x26 popup — the exact live-DAT-probed
/// shape (four-piece bevel frame around one Type-12 text child) — every
/// call, mirroring retail's own fresh-instance-per-show behaviour.</summary>
private static ImportedLayout BuildPopup()
{
var rootInfo = new ElementInfo
{
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
TooltipTextChildElementId = TextChildId,
};
var textInfo = new ElementInfo
{
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
};
return LayoutImporter.BuildFromInfos(
rootInfo, [textInfo], _ => (0u, 0, 0), null);
}
private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests)
CreateHarness()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var requests = new List<(uint LayoutDid, uint RootElementId)>();
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
{
requests.Add((layoutDid, rootElementId));
return BuildPopup();
});
return (root, presenter, requests);
}
private static HoverTarget AddFullyAuthoredTarget(UiRoot root, string text = "Rotate left.")
{
var target = new HoverTarget
{
Left = 100, Top = 100, Width = 40, Height = 20,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = text,
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = PopupLayoutDid,
};
root.AddChild(target);
return target;
}
[Fact]
public void NoTooltipShows_WhenPropertiesAbsent()
{
// Regression pin: an element that authors NONE of the five tooltip
// properties must never produce a popup, even after the dwell delay
// and the mouse resting on it.
var (root, _, requests) = CreateHarness();
var target = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
root.AddChild(target);
int childrenBefore = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs + 50);
Assert.Empty(requests);
Assert.Equal(childrenBefore, root.Children.Count);
}
[Fact]
public void DelayThenShow_MountsPopupOnlyAfterTheDwellDelay()
{
var (root, _, requests) = CreateHarness();
AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
Assert.Equal(childrenBefore, root.Children.Count); // not yet — dwell hasn't elapsed
root.Tick(0.016, root.TooltipDelayMs - 1);
Assert.Equal(childrenBefore, root.Children.Count); // still one ms short
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBefore + 1, root.Children.Count); // popup mounted
Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId));
}
[Fact]
public void GlobalEnableGateOff_SuppressesPresentation_ButTheDwellTimerStillFires()
{
// Retail gates the POPUP at UIElement::MouseHover, not the dwell
// timer itself (UIElementManager::CheckTooltip has no m_tooltipEnable
// check) — the C# TooltipShow event must still fire; only the
// presenter's own decision to build something is suppressed.
var (root, presenter, requests) = CreateHarness();
presenter.Enabled = false;
AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count;
bool eventFired = false;
root.TooltipShow += _ => eventFired = true;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.True(eventFired);
Assert.Empty(requests);
Assert.Equal(childrenBefore, root.Children.Count);
}
[Fact]
public void WidgetOwnTooltipDisabled_SuppressesPresentation()
{
var (root, _, requests) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
target.AuthoredTooltipEnabled = false;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Empty(requests);
}
[Fact]
public void MissingText_SuppressesPresentation()
{
var (root, _, requests) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
target.AuthoredTooltipText = null;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Empty(requests);
}
[Fact]
public void DismissesOnHoverTargetChange()
{
var (root, _, _) = CreateHarness();
AddFullyAuthoredTarget(root);
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
root.AddChild(other);
int childrenBeforeShow = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
// Retail UIElementManager::SwitchMouseOver @0x0045B560: hovering a
// DIFFERENT element tears down the active tooltip immediately.
root.OnMouseMove(410, 410);
Assert.Equal(childrenBeforeShow, root.Children.Count);
}
[Fact]
public void DismissesWhenTheOwnerElementIsRemoved()
{
var (root, _, _) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
int childrenBeforeShow = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
// Retail UIElementManager::DeletingElement @0x0045E520: the tooltip's
// owner going away tears the popup down too.
root.RemoveChild(target);
Assert.Equal(childrenBeforeShow - 1, root.Children.Count); // target removed, popup removed
}
[Fact]
public void AutoHidesAfterTheDurationElapses()
{
var (root, _, _) = CreateHarness();
AddFullyAuthoredTarget(root);
int childrenBeforeShow = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDurationMs);
Assert.Equal(childrenBeforeShow, root.Children.Count);
}
[Fact]
public void PerElementDelayOverride_ReplacesTheGlobalDelay()
{
var (root, _, requests) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
target.AuthoredTooltipDelaySeconds = 0f; // the live-DAT-probed override value
root.OnMouseMove(110, 110);
root.Tick(0.016, 0); // the very next tick after hover starts — no dwell wait
Assert.Single(requests);
}
[Fact]
public void PositionClampsToStayFullyOnTheDisplay()
{
var (root, _, _) = CreateHarness();
// Small canvas + a hover point near the bottom-right corner, so the
// 30x30 popup would overflow both edges without the clamp.
root.Width = 40f;
root.Height = 40f;
var target = new HoverTarget
{
Left = 0, Top = 0, Width = 40, Height = 40,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = "hi",
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = PopupLayoutDid,
};
root.AddChild(target);
root.OnMouseMove(38, 38);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.True(popup.Left + popup.Width <= 40f, $"popup right edge {popup.Left + popup.Width} exceeds canvas width 40");
Assert.True(popup.Top + popup.Height <= 40f, $"popup bottom edge {popup.Top + popup.Height} exceeds canvas height 40");
Assert.True(popup.Left >= 0f);
Assert.True(popup.Top >= 0f);
}
[Fact]
public void AutoResizesTheRootByTheMeasuredTextDelta()
{
var (root, _, _) = CreateHarness();
var target = AddFullyAuthoredTarget(
root, text: "This is a much longer tooltip than the authored placeholder.");
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.True(popup.Width > 30f, $"expected the popup to grow past its authored 30px width, got {popup.Width}");
}
}

View file

@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// #409 (client-wide retail tooltip system) installed-DAT acceptance gate.
/// Opt in with <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can
/// override the ordinary Documents/Asheron's Call location. Pins retail's
/// tooltip popup LayoutDesc <c>0x21000041</c> structure and a client-wide
/// sweep landmark, mirroring <see cref="CharacterManagementLiveDatTests"/>'s
/// pattern.
/// </summary>
public sealed class TooltipLiveDatTests
{
private static string DatDirectory =>
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
/// <summary>The tooltip popup catalog LayoutDesc — every probed
/// tooltip-bearing element's P0x48 resolves here (with one exception,
/// 0x21000026, an alternate skin used by a handful of elements —
/// RetailTooltipPresenter is data-driven off each element's OWN P0x48
/// so neither this constant nor 0x21000026 is hardcoded in production
/// code; it is cited here only to pin the probe's own finding).</summary>
private const uint TooltipCatalogLayoutId = 0x21000041u;
/// <summary>The four popup "skin" root element ids live-DAT-probed inside
/// <see cref="TooltipCatalogLayoutId"/>. Each is a 30x30 four-piece bevel
/// frame around the SAME text child id (<see cref="TooltipTextChildId"/>).</summary>
private static readonly uint[] PopupSkinRootIds =
[0x10000487u, 0x10000395u, 0x10000397u, 0x10000398u];
private const uint TooltipTextChildId = 0x10000396u;
[InstalledDatFact]
public void TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
ElementInfo? root = LayoutImporter.ImportInfos(dats, TooltipCatalogLayoutId);
Assert.NotNull(root);
Assert.Equal(0u, root!.Id);
Assert.Equal(800f, root.Width);
Assert.Equal(600f, root.Height);
foreach (uint skinRootId in PopupSkinRootIds)
{
ElementInfo skin = Assert.Single(root.Children, c => c.Id == skinRootId);
// UIElementManager::StartTooltip @0x0045DE90's own fallback read:
// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) off the
// freshly-instantiated POPUP root itself.
Assert.Equal(TooltipTextChildId, skin.TooltipTextChildElementId);
ElementInfo textChild = Assert.Single(
AllDescendants(skin), e => e.Id == TooltipTextChildId);
Assert.Equal(12u, textChild.Type); // UIElement_Text
}
}
[InstalledDatFact]
public void TooltipCatalog_ImportsThroughLayoutImporter_WithTextChildResolvable()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
ImportedLayout? popup = LayoutImporter.Import(
dats,
TooltipCatalogLayoutId,
0x10000487u,
_ => (0u, 0, 0),
null);
Assert.NotNull(popup);
Assert.Equal(30f, popup!.Root.Width);
Assert.Equal(30f, popup.Root.Height);
Assert.Equal(TooltipTextChildId, popup.Root.AuthoredTooltipTextChildElementId);
UiElement? textChild = popup.FindElement(TooltipTextChildId);
Assert.IsType<UiText>(textChild);
}
/// <summary>
/// A known tooltip-bearing element (the Appearance page's left rotate
/// button, live-DAT-probed) authors all five trigger properties with
/// literal StringInfo text — the "core" case
/// <see cref="RetailTooltipPresenter"/> can show end to end.
/// </summary>
[InstalledDatFact]
public void KnownElement_AuthorsAllFiveTooltipProperties_WithResolvableText()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x21000005u);
Assert.NotNull(tree);
ElementInfo rotateLeft = Assert.Single(AllDescendants(tree!), e => e.Id == 0x100005A4u);
Assert.True(rotateLeft.TooltipEnabled);
Assert.Equal(0x10000487u, rotateLeft.TooltipRootElementId);
Assert.Equal(TooltipCatalogLayoutId, rotateLeft.TooltipLayoutDid);
Assert.NotNull(rotateLeft.TooltipText);
var strings = new DatStringResolver(dats);
string? resolved = DatWidgetFactory.ResolveTooltipText(rotateLeft, strings.Resolve);
Assert.Equal("Rotate left.", resolved);
}
/// <summary>
/// Landmark client-wide sweep (mirrors
/// <see cref="LayoutImporterMediaBearingChildSweepTests"/>'s own shape):
/// every installed LayoutDesc, counting elements authoring at least one
/// of the five tooltip-trigger properties. Asserts landmarks + a floor,
/// not a brittle exact total, so the gate survives a future DAT
/// revision.
/// </summary>
[InstalledDatFact]
public void ClientWideSweep_FindsKnownLandmarksAndAFloorCount()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>();
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
{
ElementInfo? tree;
try { tree = LayoutImporter.ImportInfos(dats, layoutId); }
catch { continue; }
if (tree is null) continue;
foreach (ElementInfo e in AllDescendants(tree))
{
bool any = e.TooltipRootElementId != 0 || e.TooltipLayoutDid != 0
|| e.TooltipText.HasValue || e.TooltipEnabled
|| e.TooltipDelaySeconds.HasValue;
if (any)
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue));
}
}
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text).");
// #409 investigation landmark (main game UI, Appearance rotate button).
Assert.Contains(withProperties, f => f.LayoutId == 0x21000005u && f.ElementId == 0x100005A4u);
// Floor: the live-DAT sweep found 434 total / 243 with literal text at
// filing time — assert comfortably below both so a future content
// patch that only ADDS tooltip authoring cannot flake this gate.
Assert.True(withProperties.Count >= 400,
$"expected at least 400 tooltip-property-authoring elements, found {withProperties.Count}.");
Assert.True(withProperties.Count(f => f.HasText) >= 200,
$"expected at least 200 elements with literal tooltip text, found {withProperties.Count(f => f.HasText)}.");
}
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
{
yield return root;
foreach (ElementInfo child in root.Children)
foreach (ElementInfo descendant in AllDescendants(child))
yield return descendant;
}
}

View file

@ -631,4 +631,57 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default));
}
// -- #409: Misc section (Misc.TooltipEnable / Misc.TooltipDelay) --------
[Fact]
public void LoadMisc_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
Assert.Equal(MiscSettings.Default, store.LoadMisc());
}
[Fact]
public void SaveMisc_then_LoadMisc_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = new MiscSettings(TooltipEnable: false, TooltipDelaySeconds: 1.5f);
store.SaveMisc(original);
var loaded = store.LoadMisc();
Assert.Equal(original, loaded);
}
[Fact]
public void LoadMisc_falls_back_per_field_when_keys_missing()
{
File.WriteAllText(_tempPath, """
{
"version": 3,
"misc": { "tooltipEnable": false }
}
""");
var store = new SettingsStore(_tempPath);
var loaded = store.LoadMisc();
Assert.False(loaded.TooltipEnable);
Assert.Equal(MiscSettings.Default.TooltipDelaySeconds, loaded.TooltipDelaySeconds);
}
[Fact]
public void SaveMisc_preserves_display_section_and_vice_versa()
{
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveMisc(MiscSettings.Default with { TooltipDelaySeconds = 2f });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds);
store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
Assert.Equal(2f, store.LoadMisc().TooltipDelaySeconds);
Assert.True(store.LoadDisplay().ShowFps);
}
}