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

@ -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)