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,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)
{