fix(ui): #409 tooltip review fix round — F1-F11

Opus review of a377b9bf returned architectural PASS-with-findings /
retail-fidelity FAIL with F1-F12 (F12 info-only). All eleven fixed,
each re-derived against docs/research/named-retail/acclient_2013_pseudo_c.txt:

- F1 PositionAtMouse: retail offsets BOTH axes +32px before the clamp
  (StartTooltip @0x00459700, @0x00459739/@0x00459747) — was landing
  flush at the cursor.
- F2 UiRoot: the dwell timer now anchors to mouse-IDLE like retail's
  m_lastMouseMoveTime (MouseMoveHandler @0x0045E710), resetting on
  every move within the same widget while !_tooltipFired, not just on
  hover-enter.
- F3 register TS-85 rewritten: the "dynamic InqProperty(0x49) override"
  framing was false — UIElement::InqProperty @0x004638D0's base impl
  reads the same authored bags this port already reads. The real
  second text source (m_TTText/SetTooltip, headed by the P0xD0
  truncated-text auto-tooltip @0x00466F80) needs a per-line-position
  truncation model UiText doesn't have — sized disproportionate for
  this round and left honestly deferred rather than stubbed.
- F4 OnTooltipShow: null LayoutPolicy + Anchors=None on the popup root
  and text child before resizing, mirroring RetailMessageDialogView's
  sibling shape.
- F5 OnTooltipShow: return without mounting when the P0x4A text child
  doesn't resolve to a UiText (retail's DynamicCast gate,
  StartTooltip @0x0045DE90 @0x0045df65/@0x0045df6f) — was mounting an
  empty 30x30 bevel artifact.
- F6 UiRoot.Tick: the dwell-arm branch now requires Captured is null
  (CheckTooltip @0x0045B6E0 @0x0045b715) — a widget hovered before a
  drag/resize/capture began must not pop mid-gesture.
- F7 UiRoot.ReleaseCapture: no longer resets _tooltipFired
  (ReleaseMouseCapture @0x0045D2B0 touches only the idle timestamp) —
  a mouse-up while a tooltip is shown no longer tears it down and
  silently re-fires it 250ms later.
- F8 ApplyTooltipText: applies ResizeTo's own max/min width/height
  clamps (P0x3C/0x3D/0x3E/0x3F, @0x00463C30) before assigning the
  grown size; zeroes text.Padding to keep the measured size margin-
  comparable. New ElementInfo/UiElement plumbing for the four
  properties, same shape as the existing tooltip fields.
- F9 doc precision: sweep counts corrected 434->430 / 191->187 (live-
  DAT re-measured), the "243 showable" claim now measured exactly
  (not assumed) via a new Showable column in the sweep test, and the
  MiscSettings citation split into its two real mechanisms
  (RegisterPreference in Init vs. AttachPreference/SetPreferenceRange
  elsewhere).
- F10 register AD-106: the topmost guarantee is versus dialogs/screens
  only (the overlay popup layer and drag ghost still paint above
  regardless), and the per-tick BringToFront ratchet has four rungs,
  not three.
- F11 RetailUiRuntime.ResetSessionDialogs: now also calls the new
  UiRoot.ResetTooltipTracking() so a post-reset hover re-shows
  immediately instead of waiting out the stale fired-latch.

New pinning tests (RetailTooltipPresenterTests: F1/F2/F5/F6/F7/F8) each
verified to fail against the pre-fix behavior via a temporary revert-
and-rerun before being confirmed against the restored fix.
PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays
recurrence logged on issue #346 (already the tracking issue for this
load-sensitive flake) — hit twice under load this review, standalone
26/26, unrelated to #409.

Gates: Release build 0 errors; App suite (live-DAT env) 5416/5413
passed/3 skips (was 5410/5407/3, +6 new tests); Runtime 1735/0;
UI.Abstractions 926/0; full solution (no env, 69 skips expected)
14,623/14,554 passed/69 skipped/0 failed (was 14,617/14,548, +6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 20:54:15 +02:00
parent a377b9bff7
commit 2719782dc0
11 changed files with 515 additions and 58 deletions

View file

@ -107,10 +107,10 @@ 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:** CODE-COMPLETE 2026-08-16 (this commit) — pending the user's connected visual gate (see the gate note at the bottom of this entry).
**Status:** CODE-COMPLETE 2026-08-16, review-fix round F1-F11 landed same day (`fix(ui): #409 tooltip review fix round`) — 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
**2026-08-16 re-derivation + port.** 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
@ -119,11 +119,40 @@ popup LayoutDesc (`P0x48`) to instantiate as the popup's root**
`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
build) found **430 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
the other 187 have no literal text and show nothing — see register row
TS-85 for the honest scope of what's missing there), superseding the
original "~253" estimate.
**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
port above returned architectural PASS-with-findings / retail-fidelity FAIL
with twelve findings; F1-F11 landed in one fix commit (F12 was info-only).
Highlights: the popup now offsets +32px from the mouse on both axes
(`StartTooltip @0x00459700`, was landing flush at the cursor); the dwell
timer now anchors to mouse-IDLE like retail's `m_lastMouseMoveTime`, not
hover-enter, and is gated off while the mouse has capture
(`CheckTooltip @0x0045B6E0`); `ReleaseCapture` no longer tears down an
already-shown tooltip's fired latch (it only restarts the idle deadline,
matching `ReleaseMouseCapture @0x0045D2B0`); the popup no longer mounts an
empty bevel artifact when its text child fails to resolve
(`StartTooltip @0x0045DE90`'s `DynamicCast` gate); the popup and its text
child now null their anchor policy before resizing, mirroring the sibling
`RetailMessageDialogView`; the auto-resize now applies retail's
`ResizeTo @0x00463C30` max/min width/height clamps; and a session reset now
also clears `UiRoot`'s own hover/tooltip-fired latch (not just the
presenter's popup element) so a post-reset hover re-shows immediately. The
review's biggest correction was to the ORIGINAL port's own framing of what
it deferred: "191 elements rely on retail's dynamic `InqProperty(0x49)`
override" was FALSE — retail's own base `InqProperty` reads the same
authored property bags this port already reads, so most no-literal-text
elements show nothing in retail too. Register row TS-85 is rewritten (not
just re-counted) with the real gap: the `m_TTText`/`SetTooltip` runtime-text
family, headed by the `P0xD0` truncated-text auto-tooltip
(`UIElement_Text::RecalculateTruncation @0x00466F80`) — sized and found
disproportionate to port in the same round (it needs a per-line-position
truncation model this port's `UiText` doesn't have), so it stays deferred,
honestly described. 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`.
@ -152,11 +181,20 @@ 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.
**Deferred (register TS-85, rewritten at the F3 review round):** the
`m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0`
truncated-text auto-tooltip (187 of 430 tooltip-property-authoring elements
have no literal text and show nothing; an unmeasured subset of those would
show retail's truncation tooltip instead) and the `P0x3D` per-element
wrap-width override (zero elements author one today). **Deferred (register
AD-106, two honest additions at the F10 review round):** the topmost-z-order
mechanism is a sibling-with-later-reraise adaptation, not retail's literal
separate presentation layer — the guarantee is versus dialogs/screens ONLY
(the overlay popup layer and the drag ghost still paint above regardless),
and the per-tick `BringToFront` re-raise chain now has four rungs
(`CharacterManagementUiController`, `CharacterCreationUiController`,
`RetailDialogFactory`, `RetailTooltipPresenter`) — bounded and enumerable
today, but a design smell worth flagging.
**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
@ -2893,6 +2931,18 @@ two subsequent full runs. Same FILE as #302 but a DIFFERENT test — filed
separately per the never-conflate rule.
**Filed:** 2026-08-08, observed during #344's suite runs.
**2026-08-16 recurrence (Campaign #409 tooltip review-fix round).** The Opus
reviewer of `a377b9bf` hit this same assertion failing TWICE under
full-solution load, unrelated to any #409 tooltip code; passes standalone
26/26. Joining the same running known-flake set the CC7 gate row already
names (`docs/plans/2026-08-15-character-creation-campaign.md`, CC7 row) for
Core.Net `NakEmissionTests.LossSoak_...` / Content `DecodedTextureCacheTests`
/ App `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` /
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
a full-solution gate hitting exactly one of these five allocation/timing-
sensitive tests, with a clean standalone or immediate-rerun pass, is this
known class, not a new regression.
---
## #344 — Mid-teleport crash: world-frame owners disagree during a long portal into a dungeon

File diff suppressed because one or more lines are too long

View file

@ -298,6 +298,28 @@ public sealed class ElementInfo
/// design; resolution through <c>DatStringResolver</c> happens in
/// <c>DatWidgetFactory</c>, mirroring the existing <c>0x17</c> caption
/// pattern exactly.
///
/// <para>
/// F3 correction (2026-08-16 review round): the vtable dispatch through
/// <c>InqProperty(0x49, ...)</c> is NOT itself a "dynamic override"
/// mechanism for a plain element. <c>UIElement::InqProperty
/// @0x004638D0</c> — the BASE implementation every element uses unless
/// its own class overrides the virtual — reads exactly the same
/// authored property bags this reader already walks
/// (<c>m_instanceProperties</c>, <c>m_curStateDesc</c>, <c>m_desc</c>).
/// So an element with no literal <c>P0x49</c> gets NOTHING back from
/// retail's own default <c>InqProperty</c> either — the earlier framing
/// ("191 elements rely on retail's dynamic InqProperty(0x49) override")
/// overstated what that fallback does for the general case. The REAL
/// second text source is <c>m_TTText</c> via the explicit
/// <c>UIElement::SetTooltip</c> call family (~15+ game-code call
/// sites), headed by the truncated-text auto-tooltip
/// (<c>UIElement_Text::RecalculateTruncation @0x00466F80</c>, gated on
/// authored <c>P0xD0</c>: an overflowing line calls
/// <c>SetTooltip(this, ownText) @0x00467064</c>; a line that now fits
/// calls <c>ClearTooltip @0x00467076</c>). See register row TS-85 for
/// the honest scope of what remains unported.
/// </para>
/// </summary>
public UiStringInfoValue? TooltipText;
@ -355,6 +377,35 @@ public sealed class ElementInfo
/// </summary>
public float? TooltipDelaySeconds;
/// <summary>
/// #409 F8: retail's per-element auto-resize WIDTH clamps — dat
/// properties <c>0x3D</c> (max, <c>IntegerBaseProperty</c>) and
/// <c>0x3F</c> (min). <c>UIElement::ResizeTo @0x00463C30</c> reads both
/// via <c>GetAttribute_Int</c> (<c>@0x00463c9c</c>/<c>@0x00463cba</c>)
/// and clamps a caller-requested width to <c>[min, max]</c> BEFORE
/// applying it — <see cref="RetailTooltipPresenter.ApplyTooltipText"/>
/// is the one caller in this port that grows an element via that path.
/// Same property/direction mapping <c>RetailWindowFrame.ResolveConstraint</c>
/// already uses for window frames (0x3D=&gt;MaxWidth, 0x3F=&gt;MinWidth), ported
/// independently here since that helper only runs at window-mount time
/// off a caller-supplied <see cref="ElementInfo"/>, not generically for
/// every imported element. Null = no authored override (retail's
/// <c>GetAttribute_Int</c> returning false leaves the requested value
/// unclamped on that side).
/// </summary>
public int? MaxWidth;
public int? MinWidth;
/// <summary>
/// #409 F8: retail's per-element auto-resize HEIGHT clamps — dat
/// properties <c>0x3C</c> (max) and <c>0x3E</c> (min), read by the same
/// <c>UIElement::ResizeTo @0x00463C30</c> (<c>@0x00463c64</c>/
/// <c>@0x00463c80</c>). See <see cref="MaxWidth"/>'s doc comment for the
/// full citation; this pair is the height side of the same clamp.
/// </summary>
public int? MaxHeight;
public int? MinHeight;
/// <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.
@ -718,6 +769,19 @@ public static class ElementReader
{
info.TooltipDelaySeconds = tooltipDelay;
}
// #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F
// — see MaxWidth's/MaxHeight's own doc comments for the decomp
// anchor). Read generically for every element, same shape as the
// margins above; only RetailTooltipPresenter consumes them today.
if (info.TryGetEffectiveInteger(0x3Du, out int maxWidth))
info.MaxWidth = maxWidth;
if (info.TryGetEffectiveInteger(0x3Fu, out int minWidth))
info.MinWidth = minWidth;
if (info.TryGetEffectiveInteger(0x3Cu, out int maxHeight))
info.MaxHeight = maxHeight;
if (info.TryGetEffectiveInteger(0x3Eu, out int minHeight))
info.MinHeight = minHeight;
}
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)

View file

@ -133,6 +133,12 @@ public static class LayoutImporter
w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId;
w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds;
// #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F).
w.AuthoredResizeMaxWidth = info.MaxWidth;
w.AuthoredResizeMinWidth = info.MinWidth;
w.AuthoredResizeMaxHeight = info.MaxHeight;
w.AuthoredResizeMinHeight = info.MinHeight;
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat

View file

@ -29,8 +29,9 @@ namespace AcDream.App.UI.Layout;
/// 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>
/// positions the popup's top-left at the mouse cursor OFFSET by 32px on
/// BOTH axes (<c>@0x00459739</c>/<c>@0x00459747</c>), clamped to the
/// display.</description></item>
/// </list>
/// </para>
///
@ -40,14 +41,17 @@ namespace AcDream.App.UI.Layout;
/// (<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
/// to exactly <c>0x10000396</c>, confirming the decomp reading. 430 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.
/// <c>P0x49</c> text this port shows; the other 187 have no literal text —
/// see register row TS-85, corrected at the 2026-08-16 review round: retail's
/// OWN default <c>InqProperty(0x49)</c> reads the SAME authored bags this
/// port already reads, so most of those 187 show nothing in retail either;
/// the real gap is the separate <c>m_TTText</c>/<c>SetTooltip</c> family
/// headed by the <c>P0xD0</c> truncated-text auto-tooltip). 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
@ -76,8 +80,10 @@ public sealed class RetailTooltipPresenter : IDisposable
/// <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>,
/// (<c>UIElementManager::Init @0x0045EE10</c> registers the LIVE binding
/// via <c>UserPreferences::RegisterPreference @0x0045EE92</c> — see
/// <see cref="AcDream.UI.Abstractions.Panels.Settings.MiscSettings"/>'s
/// own doc comment for the full two-mechanism citation split —
/// 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-
@ -120,8 +126,31 @@ public sealed class RetailTooltipPresenter : IDisposable
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
? layout.FindElement(root.AuthoredTooltipTextChildElementId)
: null;
if (textChild is UiText text)
ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
// F5: retail requires GetChildRecursive to resolve AND DynamicCast
// to UIElement_Text (type 0xc) before it ever calls the positioning/
// show half of StartTooltip (UIElementManager::StartTooltip
// @0x0045DE90, @0x0045df59/@0x0045df65 — @0x0045df6f gates the rest
// of the function on a non-null cast result). Every popup skin this
// port's live-DAT sweep found resolves cleanly, so this only guards
// a malformed/future LayoutDesc — but mounting anyway there would
// show an empty, unsized 30x30 bevel artifact instead of retail's
// silent no-op.
if (textChild is not UiText text)
return;
// F4: null the per-frame anchor recompute on BOTH the popup root and
// its text child before resizing, the same shape the sibling
// RetailMessageDialogView uses for its popup/message pair
// (RetailMessageDialogView.cs:49-53) — ApplyAnchor otherwise
// recomputes margins against the popup's authored edge mode every
// frame, which would silently fight the resize below if that mode
// isn't the assumed "no stretch" default.
root.LayoutPolicy = null;
root.Anchors = AnchorEdges.None;
text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
SetClickThroughRecursive(root);
PositionAtMouse(root);
@ -162,11 +191,31 @@ public sealed class RetailTooltipPresenter : IDisposable
/// 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>
/// structural no-op here.
///
/// <para>
/// F8 additions (2026-08-16 review round): (1) <c>InqSizewMargins</c>
/// returns a size with the text element's own margins already added
/// back in (<c>@0x004697a4</c>/<c>@0x004697bc</c>: <c>+= m_margR +
/// m_margL</c> / <c>+= m_margD + m_margU</c>) before <c>StartTooltip</c>
/// diffs it against the AUTHORED width/height — zeroing
/// <see cref="UiText.Padding"/> here (same shape the sibling
/// <c>RetailMessageDialogView</c> uses for its own message child,
/// <c>RetailMessageDialogView.cs:53</c>) keeps our margin-free measured
/// size exactly comparable, without needing to separately track and
/// re-add an inset this port doesn't otherwise model for the tooltip
/// text child. (2) the grown size is clamped through the SAME
/// authored-override clamp <c>UIElement::ResizeTo @0x00463C30</c>
/// applies to every resize (<c>P0x3C</c> max-height/<c>P0x3E</c>
/// min-height/<c>P0x3D</c> max-width/<c>P0x3F</c> min-width, max
/// checked before min on each axis) before the value is ever assigned —
/// this port had been assigning the grown size directly, unclamped.
/// </para></summary>
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
{
float authoredTextWidth = text.Width;
float authoredTextHeight = text.Height;
text.Padding = 0f;
Func<string, float> measure = text.DatFont is { } datFont
? datFont.MeasureWidth
@ -184,21 +233,46 @@ public sealed class RetailTooltipPresenter : IDisposable
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
float measuredHeight = wrapped.Count * lineHeight;
root.Width += measuredWidth - authoredTextWidth;
root.Height += measuredHeight - authoredTextHeight;
float requestedWidth = root.Width + (measuredWidth - authoredTextWidth);
float requestedHeight = root.Height + (measuredHeight - authoredTextHeight);
// ResizeTo's own clamp order: max first, then min, independently
// per axis (@0x00463c64/@0x00463c80 for height, @0x00463c9c/
// @0x00463cba for width).
if (root.AuthoredResizeMaxHeight is { } maxHeight && requestedHeight > maxHeight)
requestedHeight = maxHeight;
if (root.AuthoredResizeMinHeight is { } minHeight && requestedHeight < minHeight)
requestedHeight = minHeight;
if (root.AuthoredResizeMaxWidth is { } maxWidth && requestedWidth > maxWidth)
requestedWidth = maxWidth;
if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth)
requestedWidth = minWidth;
root.Width = requestedWidth;
root.Height = requestedHeight;
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>
/// popup's top-left lands at the mouse cursor OFFSET by <see cref="MouseOffsetPx"/>
/// on BOTH axes (<c>@0x00459739</c>: <c>mouseX + 0x20</c>; <c>@0x00459747</c>:
/// <c>mouseY + 0x20</c>), clamped so it never crosses the right/bottom
/// display edge (nor goes negative, mirroring retail's own
/// <c>max(0, ...)</c> defensive clamp, <c>@0x00459753</c>/<c>@0x00459769</c>).
/// Retail's own clamp ORDER (max-vs-display computed against the raw
/// offset mouse position, not the already-floored one — <c>@0x00459784</c>-
/// <c>@0x0045979c</c>) can go negative when the popup is bigger than the
/// display; <see cref="Math.Clamp"/> cannot express that (its <c>min</c>
/// must be <![CDATA[<=]]> <c>max</c>) — immaterial for every popup this port
/// builds (fixed 30x30 skins plus a bounded auto-grow), left unmatched.</summary>
private const float MouseOffsetPx = 32f;
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));
float x = Math.Clamp(_host.MouseX + MouseOffsetPx, 0, MathF.Max(0f, canvas.X - root.Width));
float y = Math.Clamp(_host.MouseY + MouseOffsetPx, 0, MathF.Max(0f, canvas.Y - root.Height));
root.Left = x;
root.Top = y;
}

View file

@ -632,7 +632,7 @@ public sealed class RetailUiRuntime : IDisposable
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>
/// alongside <see cref="DialogFactory"/> — see <c>MountTooltipPresenter</c>.</summary>
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
@ -989,6 +989,13 @@ public sealed class RetailUiRuntime : IDisposable
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
TooltipPresenter?.HideCurrent();
// #409 F11: HideCurrent only tears down the presenter's own
// popup element — it leaves UiRoot's own hover-dwell/tooltip-
// shown latch untouched. Without this, a reconnect while the
// mouse never left the hovered widget leaves _tooltipFired
// latched true and the tooltip cannot re-arm until either the
// 10 s auto-hide timeout or a hover-target change.
Host.Root.ResetTooltipTracking();
}
finally
{

View file

@ -124,6 +124,24 @@ public abstract class UiElement
/// </summary>
public float? AuthoredTooltipDelaySeconds { get; internal set; }
/// <summary>
/// #409 F8: mirrors <c>ElementInfo.MaxWidth</c>/<c>MinWidth</c> (dat
/// properties <c>0x3D</c>/<c>0x3F</c>) — the <c>UIElement::ResizeTo
/// @0x00463C30</c> auto-resize width clamp. Null = no authored
/// override on that side.
/// </summary>
public int? AuthoredResizeMaxWidth { get; internal set; }
public int? AuthoredResizeMinWidth { get; internal set; }
/// <summary>
/// #409 F8: mirrors <c>ElementInfo.MaxHeight</c>/<c>MinHeight</c> (dat
/// properties <c>0x3C</c>/<c>0x3E</c>) — the height side of the same
/// <c>UIElement::ResizeTo @0x00463C30</c> clamp. Null = no authored
/// override on that side.
/// </summary>
public int? AuthoredResizeMaxHeight { get; internal set; }
public int? AuthoredResizeMinHeight { get; internal set; }
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>

View file

@ -523,7 +523,11 @@ public sealed class UiRoot : UiElement
// 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
// #409 F6: retail's arm branch is gated `m_pElementWithMouseCapture
// == 0` (UIElementManager::CheckTooltip @0x0045B6E0, @0x0045b715) —
// a widget hovered before a drag/resize/scrollbar-thumb capture
// began must not pop a tooltip mid-gesture.
if (_hoverWidget is not null && !_tooltipFired && Captured is null
&& _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget))
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
@ -1112,9 +1116,14 @@ public sealed class UiRoot : UiElement
{
UiElement? previous = Captured;
Captured = null;
// Retail restarts the tooltip idle deadline when capture is released.
// #409 F7: retail restarts the tooltip idle deadline when capture is
// released (ReleaseMouseCapture @0x0045D2B0, @0x0045d2ce/@0x0045d2da
// — m_lastMouseMoveTime only). It does NOT touch m_bHoverStarted
// (our _tooltipFired): that field means "hover started" (a tooltip
// is showing), not "capture is active", and a mouse-up while a
// tooltip is already up must leave it up, not clear-then-re-fire it
// 250ms later without ever going through TooltipHide.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
NotifyCaptureLost(previous);
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
@ -1271,6 +1280,28 @@ public sealed class UiRoot : UiElement
// ── Hover / tooltip ─────────────────────────────────────────────────
/// <summary>
/// #409 F11: forgets the in-progress hover-dwell/tooltip-shown latch
/// without touching <see cref="_hoverWidget"/> itself or firing any
/// hover-leave event. <see cref="RetailUiRuntime.ResetSessionDialogs"/>
/// calls this alongside <see cref="RetailTooltipPresenter.HideCurrent"/>
/// (which only tears down the presenter's own popup element) so a
/// reconnect that hides an in-flight tooltip also re-arms the dwell
/// timer — otherwise a mouse that never left the hovered widget across
/// the reset would leave <c>_tooltipFired</c> latched true and the
/// widget would not show a tooltip again until either the 10 s
/// auto-hide timeout elapses or the hover target changes. No literal
/// retail counterpart (retail's own session teardown is a full
/// <c>UIElementManager</c> re-construction, not a partial reset), but
/// the effect matches: a fresh dwell deadline, same as
/// <see cref="ReleaseCapture"/>'s own idle-timestamp restart.
/// </summary>
public void ResetTooltipTracking()
{
_hoverStartedMs = _nowMs;
_tooltipFired = false;
}
private void UpdateHover(int x, int y)
{
// An open popup claims hover first (#374) — its item highlight must
@ -1282,6 +1313,19 @@ public sealed class UiRoot : UiElement
{
if (w?.ReceivesHoverMouseMove == true)
DispatchMouseMove(w, x, y);
// #409 F2: retail's dwell timer anchors to mouse-IDLE, not
// hover-enter — UIElementManager::MouseMoveHandler @0x0045E710
// stamps m_lastMouseMoveTime on EVERY move (@0x0045e729/
// @0x0045e736), unconditionally, before any hit-testing; the
// arm check in CheckTooltip @0x0045B6E0 (@0x0045b747) compares
// against that timestamp. Jiggling the mouse within the SAME
// widget must keep re-arming the deadline, not just entering
// it once. Guarded by !_tooltipFired — retail's m_bHoverStarted
// (our _tooltipFired) keeps a SHOWN tooltip from being
// re-armed by further moves; ReleaseCapture already ports the
// same field's other writer (@0x0045D2B0).
if (!_tooltipFired)
_hoverStartedMs = _nowMs;
return;
}

View file

@ -2,19 +2,29 @@ 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.
/// <c>UserPreferences.ini</c> keys, <c>Misc_TooltipDelay</c>/
/// <c>Misc_TooltipEnable</c>. F9 correction (2026-08-16 review round): two
/// DISTINCT retail mechanisms touch these, not one —
/// <c>UIElementManager::Init @0x0045EE10</c> calls
/// <c>UserPreferences::RegisterPreference</c> (<c>@0x0045EE41</c> for
/// <c>Misc_TooltipDelay</c>, <c>@0x0045EE92</c> for
/// <c>Misc_TooltipEnable</c>) to bind the LIVE runtime variables
/// (<c>UIElementManager::m_tooltipDelay</c>/<c>m_tooltipEnable</c>) to their
/// named preference with a change callback; a SEPARATE preference-metadata
/// table builder (containing <c>@0x00403811</c>-<c>@0x0040384E</c>) calls
/// <c>UIPreferences::AttachPreference</c> (declaring the name/kind/help-text
/// hash) and, for the float, <c>UIPreferences::SetPreferenceRange</c>
/// (<c>@0x00403821</c>, the <c>[0,10]</c> clamp). 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 are attached to the global
/// preference registry, but <c>gmConfigUI::InitOptions</c>'s 27 authored
/// rows — a DIFFERENT, later function that decides which attached
/// preferences actually get a widget — 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,

View file

@ -265,4 +265,173 @@ public sealed class RetailTooltipPresenterTests
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}");
}
// ── 2026-08-16 review fix round (F1-F11) pins ──────────────────────
[Fact]
public void F1_PositionsAtMouse_OffsetBy32PixelsOnBothAxes()
{
// UIElementManager::StartTooltip @0x00459700 adds a 32px (0x20)
// offset on BOTH axes before clamping (@0x00459739/@0x00459747) —
// the popup must not land flush at the cursor.
var (root, _, _) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
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.Equal(142f, popup.Left); // 110 + 32
Assert.Equal(142f, popup.Top); // 110 + 32
}
[Fact]
public void F2_MouseMoveWithinTheSameWidget_ResetsTheDwellClock()
{
// Retail's dwell timer anchors to mouse-IDLE, not hover-enter —
// UIElementManager::MouseMoveHandler @0x0045E710 stamps
// m_lastMouseMoveTime on EVERY move (@0x0045e729/@0x0045e736);
// CheckTooltip @0x0045B6E0 (@0x0045b747) compares against that.
// Jiggling the mouse within the SAME widget must keep pushing the
// deadline out, not leave the original hover-enter time in place.
var (root, _, requests) = CreateHarness();
AddFullyAuthoredTarget(root);
root.OnMouseMove(110, 110); // hover starts at nowMs=0
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs - 10); // nowMs=240, 10ms short
Assert.Empty(requests);
// Jiggle within the same widget at nowMs=240 — resets the deadline.
root.OnMouseMove(111, 111);
root.Tick(0.016, root.TooltipDelayMs); // nowMs=250 — the OLD deadline
Assert.Empty(requests); // must NOT have fired yet if the clock reset
root.Tick(0.016, (root.TooltipDelayMs - 10) + root.TooltipDelayMs); // nowMs=490 (240+250)
Assert.Single(requests);
}
[Fact]
public void F5_TextChildResolvesToSomethingOtherThanUiText_NeverMounts()
{
// Retail requires GetChildRecursive to resolve AND DynamicCast to
// UIElement_Text (type 0xc) before StartTooltip @0x0045DE90's
// positioning/show half ever runs (@0x0045df59/@0x0045df65/
// @0x0045df6f) — a text-child id that resolves to something else
// must produce NO popup at all, not an empty unsized bevel.
var rootInfo = new ElementInfo
{
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
TooltipTextChildElementId = TextChildId,
};
var notTextInfo = new ElementInfo
{
Id = TextChildId, Type = 3, X = 2, Y = 2, Width = 26, Height = 26, // type 3, NOT 12 -> UiDatElement
};
var root = new UiRoot { Width = 800f, Height = 600f };
var requests = new List<(uint, uint)>();
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
{
requests.Add((layoutDid, rootElementId));
return LayoutImporter.BuildFromInfos(rootInfo, [notTextInfo], _ => (0u, 0, 0), null);
});
AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Single(requests); // the layout WAS built...
Assert.Equal(childrenBefore, root.Children.Count); // ...but never mounted
}
[Fact]
public void F6_MouseCaptureElsewhere_SuppressesTheDwellArm_UntilCaptureReleases()
{
// CheckTooltip @0x0045B6E0's arm branch is gated
// `m_pElementWithMouseCapture == 0` (@0x0045b715) — a widget
// hovered before a drag/resize/scrollbar-thumb capture began must
// not pop a tooltip mid-gesture.
var (root, _, requests) = CreateHarness();
AddFullyAuthoredTarget(root);
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
root.AddChild(other);
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.SetCapture(other);
root.Tick(0.016, root.TooltipDelayMs + 50);
Assert.Empty(requests); // captured elsewhere — must not arm
root.ReleaseCapture(); // restarts the idle deadline at nowMs=300
root.Tick(0.016, root.TooltipDelayMs + 50 + root.TooltipDelayMs + 50);
Assert.Single(requests); // now arms normally
}
[Fact]
public void F7_ReleaseCaptureWhileATooltipIsAlreadyShown_DoesNotHideAndReshowIt()
{
// ReleaseMouseCapture @0x0045D2B0 touches ONLY the idle timestamp
// (m_lastMouseMoveTime), never m_bHoverStarted — a mouse-up while a
// tooltip is already up must leave it up, not clear-then-re-fire it
// 250ms later without ever going through TooltipHide.
var (root, _, requests) = CreateHarness();
var target = AddFullyAuthoredTarget(root);
int childrenBeforeShow = root.Children.Count;
bool hideFired = false;
root.TooltipHide += _ => hideFired = true;
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // tooltip showing
Assert.Single(requests);
root.SetCapture(target);
root.ReleaseCapture(); // restarts the idle deadline at nowMs=TooltipDelayMs
// Advance PAST where the buggy old code's re-armed deadline would
// land (TooltipDelayMs + TooltipDelayMs) — the bug reset
// _tooltipFired=false here, so the dwell-arm branch would refire
// OnTooltipShow (RemovePopup + rebuild) a full delay later, even
// though nothing about the hover ever changed.
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDelayMs + 10);
Assert.False(hideFired, "tooltip must not hide on a mere capture release");
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // still showing
Assert.Single(requests); // still exactly one OnTooltipShow call, not re-fired
}
[Fact]
public void F8_AutoResizeAppliesTheAuthoredMaxWidthClamp()
{
// UIElement::ResizeTo @0x00463C30 clamps the auto-grown size
// against P0x3C/0x3D/0x3E/0x3F BEFORE assigning it.
var rootInfo = new ElementInfo
{
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
TooltipTextChildElementId = TextChildId,
MaxWidth = 40, // P0x3D
};
var textInfo = new ElementInfo
{
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
};
var root = new UiRoot { Width = 800f, Height = 600f };
var presenter = new RetailTooltipPresenter(root, (_, _) =>
LayoutImporter.BuildFromInfos(rootInfo, [textInfo], _ => (0u, 0, 0), null));
var target = AddFullyAuthoredTarget(
root,
text: "This is a much longer tooltip than the authored placeholder, long "
+ "enough to want to grow well past forty pixels wide.");
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.Equal(40f, popup.Width); // clamped, not the larger natural measured size
}
}

View file

@ -127,7 +127,7 @@ public sealed class TooltipLiveDatTests
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText)>();
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText, bool Showable)>();
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
{
ElementInfo? tree;
@ -141,22 +141,37 @@ public sealed class TooltipLiveDatTests
|| e.TooltipText.HasValue || e.TooltipEnabled
|| e.TooltipDelaySeconds.HasValue;
if (any)
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue));
{
// F9 (2026-08-16 review round): "with literal text" alone
// over-counts what RetailTooltipPresenter.OnTooltipShow
// actually shows — that gate is a FULL AND across
// TooltipEnabled (P0x4B) AND non-null text (P0x49) AND
// both popup-locator ids (P0x47/P0x48), not just text
// presence. Measure the real intersection instead of
// assuming "243 with text" == "243 showable".
bool showable = e.TooltipEnabled && e.TooltipText.HasValue
&& e.TooltipLayoutDid != 0 && e.TooltipRootElementId != 0;
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue, showable));
}
}
}
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text).");
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text, "
+ $"{withProperties.Count(f => f.Showable)} pass the full OnTooltipShow gate).");
// #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.
// Floor: the live-DAT sweep found 430 total / 243 with literal text /
// 243 fully showable (2026-08-16, both post-F9 measurements) —
// assert comfortably below all three 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)}.");
Assert.True(withProperties.Count(f => f.Showable) >= 200,
$"expected at least 200 fully showable elements, found {withProperties.Count(f => f.Showable)}.");
}
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)