Merge campaign-409-tooltips: #409 — the client-wide retail tooltip system
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Retail's five-part tooltip mechanism ported end to end: authored trigger properties (P0x47-0x4B, P0x50) through the importer, the RetailTooltipPresenter instantiating the authored 0x21000041 popup with retail's auto-resize/clamps, the +32px cursor offset with display clamping, mouse-idle dwell (0.25s default, per-element override), 10s auto-hide, retail's exact dismissal set, capture suppression, and the Misc TooltipEnable/Delay client-local preferences. 243 authored-text elements light up client-wide. Opus dual-lens review + F1-F11 fix round complete; TS-85 records the honestly-deferred m_TTText/SetTooltip family headed by the P0xD0 truncated-text auto-tooltip; AD-106 records the z-order adaptation. Pending: the user's visual gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
ebb227c1f6
16 changed files with 1846 additions and 12 deletions
121
docs/ISSUES.md
121
docs/ISSUES.md
|
|
@ -107,8 +107,113 @@ 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)
|
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
|
||||||
|
|
||||||
**Status:** OPEN
|
**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 ~253 authored elements client-wide and acdream shows none)
|
**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.** 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 **430 elements author at least one of the five trigger
|
||||||
|
properties** (243 with literal `P0x49` `StringInfo` text this port shows;
|
||||||
|
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`.
|
||||||
|
|
||||||
|
**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, 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
|
||||||
|
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
|
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
|
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16
|
||||||
|
|
@ -2826,6 +2931,18 @@ two subsequent full runs. Same FILE as #302 but a DIFFERENT test — filed
|
||||||
separately per the never-conflate rule.
|
separately per the never-conflate rule.
|
||||||
**Filed:** 2026-08-08, observed during #344's suite runs.
|
**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
|
## #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
|
|
@ -1183,4 +1183,21 @@ public static class DatWidgetFactory
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? NormalizeEscapes(string? raw) =>
|
private static string? NormalizeEscapes(string? raw) =>
|
||||||
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,146 @@ public sealed class ElementInfo
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
|
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->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.
|
||||||
|
///
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
/// <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, &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, &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>
|
||||||
|
/// #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=>MaxWidth, 0x3F=>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>
|
/// <summary>
|
||||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
/// 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.
|
/// named state's key overrides DirectState by presence, including false/zero.
|
||||||
|
|
@ -603,6 +743,45 @@ public static class ElementReader
|
||||||
{
|
{
|
||||||
info.Invisible = invisible;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #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)
|
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,24 @@ public static class LayoutImporter
|
||||||
// doc comment for why this does NOT set Visible here.
|
// doc comment for why this does NOT set Visible here.
|
||||||
w.AuthoredInvisible = info.Invisible;
|
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;
|
||||||
|
|
||||||
|
// #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;
|
if (info.Id != 0) byId[info.Id] = w;
|
||||||
|
|
||||||
// Behavioral widgets that draw their full appearance + reproduce their dat
|
// Behavioral widgets that draw their full appearance + reproduce their dat
|
||||||
|
|
|
||||||
318
src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
Normal file
318
src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
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 OFFSET by 32px on
|
||||||
|
/// BOTH axes (<c>@0x00459739</c>/<c>@0x00459747</c>), 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. 430 installed
|
||||||
|
/// elements author at least one of the five properties (243 with literal
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
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 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-
|
||||||
|
/// 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;
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
_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.
|
||||||
|
///
|
||||||
|
/// <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
|
||||||
|
: 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;
|
||||||
|
|
||||||
|
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 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 + 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -528,6 +528,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountIndicators();
|
MountIndicators();
|
||||||
MountJumpPowerbar();
|
MountJumpPowerbar();
|
||||||
MountDialogFactory();
|
MountDialogFactory();
|
||||||
|
MountTooltipPresenter();
|
||||||
MountSocialPanel();
|
MountSocialPanel();
|
||||||
MountCharacter();
|
MountCharacter();
|
||||||
MountPlugins();
|
MountPlugins();
|
||||||
|
|
@ -630,6 +631,9 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||||
public InventoryController? InventoryPanelController { get; private set; }
|
public InventoryController? InventoryPanelController { get; private set; }
|
||||||
public RetailDialogFactory? DialogFactory { get; private set; }
|
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||||
|
/// <summary>#409: client-wide retail hover-tooltip presenter. Constructed
|
||||||
|
/// alongside <see cref="DialogFactory"/> — see <c>MountTooltipPresenter</c>.</summary>
|
||||||
|
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
|
||||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||||
public VendorUiController? VendorController { get; private set; }
|
public VendorUiController? VendorController { get; private set; }
|
||||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||||
|
|
@ -813,6 +817,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
CharacterCreationController?.Tick();
|
CharacterCreationController?.Tick();
|
||||||
DialogFactory?.Tick();
|
DialogFactory?.Tick();
|
||||||
Host.Tick(deltaSeconds);
|
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);
|
_automation?.Tick(deltaSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -979,6 +988,14 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
CharacterManagementController?.ResetSession();
|
CharacterManagementController?.ResetSession();
|
||||||
DialogFactory?.Reset();
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -3314,6 +3331,47 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
|
$"[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()
|
private UiShortcutDigitGraphics LoadShortcutDigitGraphics()
|
||||||
{
|
{
|
||||||
if (_shortcutDigitGraphics is not null)
|
if (_shortcutDigitGraphics is not null)
|
||||||
|
|
@ -4194,7 +4252,11 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_characterCreationMount?.Dispose();
|
_characterCreationMount?.Dispose();
|
||||||
_gameplayConfirmationController?.Dispose();
|
_gameplayConfirmationController?.Dispose();
|
||||||
},
|
},
|
||||||
() => DialogFactory?.Dispose(),
|
() =>
|
||||||
|
{
|
||||||
|
DialogFactory?.Dispose();
|
||||||
|
TooltipPresenter?.Dispose();
|
||||||
|
},
|
||||||
_panelUi.Dispose,
|
_panelUi.Dispose,
|
||||||
Host.Dispose);
|
Host.Dispose);
|
||||||
_shutdown.CompleteOrThrow();
|
_shutdown.CompleteOrThrow();
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,78 @@ public abstract class UiElement
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool AuthoredInvisible { get; internal set; }
|
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; }
|
||||||
|
|
||||||
|
/// <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();
|
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
||||||
|
|
||||||
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
||||||
|
|
|
||||||
|
|
@ -330,11 +330,51 @@ public sealed class UiRoot : UiElement
|
||||||
// Hover / tooltip tracking.
|
// Hover / tooltip tracking.
|
||||||
private UiElement? _hoverWidget;
|
private UiElement? _hoverWidget;
|
||||||
private long _hoverStartedMs;
|
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;
|
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;
|
public int TooltipDurationMs { get; set; } = 10_000;
|
||||||
private bool _tooltipFired;
|
private bool _tooltipFired;
|
||||||
private long _tooltipShownMs;
|
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;
|
private long _nowMs;
|
||||||
|
|
||||||
/// <summary>Raised when an event was not consumed by any widget.</summary>
|
/// <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);
|
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
|
||||||
_hoverWidget.OnEvent(in leave);
|
_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;
|
_hoverWidget = null;
|
||||||
_tooltipFired = false;
|
_tooltipFired = false;
|
||||||
}
|
}
|
||||||
|
|
@ -475,21 +521,27 @@ public sealed class UiRoot : UiElement
|
||||||
{
|
{
|
||||||
_nowMs = nowMs;
|
_nowMs = nowMs;
|
||||||
|
|
||||||
// Tooltip timer: once mouse has hovered over the same widget for
|
// Tooltip timer: once mouse has hovered over the same widget for its
|
||||||
// TooltipDelayMs, fire a Tooltip event on it exactly once.
|
// 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
|
||||||
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
|
// == 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);
|
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
|
||||||
_hoverWidget.OnEvent(in e);
|
_hoverWidget.OnEvent(in e);
|
||||||
_tooltipFired = true;
|
_tooltipFired = true;
|
||||||
_tooltipShownMs = _nowMs;
|
_tooltipShownMs = _nowMs;
|
||||||
|
TooltipShow?.Invoke(_hoverWidget);
|
||||||
}
|
}
|
||||||
else if (_hoverWidget is not null && _tooltipFired
|
else if (_hoverWidget is not null && _tooltipFired
|
||||||
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
|
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
|
||||||
{
|
{
|
||||||
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
||||||
_hoverWidget.OnEvent(in leave);
|
_hoverWidget.OnEvent(in leave);
|
||||||
|
TooltipHide?.Invoke(_hoverWidget);
|
||||||
_hoverWidget = null;
|
_hoverWidget = null;
|
||||||
_tooltipFired = false;
|
_tooltipFired = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1064,9 +1116,14 @@ public sealed class UiRoot : UiElement
|
||||||
{
|
{
|
||||||
UiElement? previous = Captured;
|
UiElement? previous = Captured;
|
||||||
Captured = null;
|
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;
|
_hoverStartedMs = _nowMs;
|
||||||
_tooltipFired = false;
|
|
||||||
NotifyCaptureLost(previous);
|
NotifyCaptureLost(previous);
|
||||||
if (previous is not null)
|
if (previous is not null)
|
||||||
PointerCaptureChanged?.Invoke(previous, null);
|
PointerCaptureChanged?.Invoke(previous, null);
|
||||||
|
|
@ -1223,6 +1280,28 @@ public sealed class UiRoot : UiElement
|
||||||
|
|
||||||
// ── Hover / tooltip ─────────────────────────────────────────────────
|
// ── 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)
|
private void UpdateHover(int x, int y)
|
||||||
{
|
{
|
||||||
// An open popup claims hover first (#374) — its item highlight must
|
// An open popup claims hover first (#374) — its item highlight must
|
||||||
|
|
@ -1234,6 +1313,19 @@ public sealed class UiRoot : UiElement
|
||||||
{
|
{
|
||||||
if (w?.ReceivesHoverMouseMove == true)
|
if (w?.ReceivesHoverMouseMove == true)
|
||||||
DispatchMouseMove(w, x, y);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1241,6 +1333,16 @@ public sealed class UiRoot : UiElement
|
||||||
{
|
{
|
||||||
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
||||||
_hoverWidget.OnEvent(in leave);
|
_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;
|
_hoverWidget = w;
|
||||||
_hoverStartedMs = _nowMs;
|
_hoverStartedMs = _nowMs;
|
||||||
|
|
|
||||||
43
src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
Normal file
43
src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #409 (client-wide retail tooltip system): the two <c>Misc.*</c>
|
||||||
|
/// <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,
|
||||||
|
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(&Misc_TooltipDelay, 0f, 10f)</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static MiscSettings Default { get; } = new(
|
||||||
|
TooltipEnable: true,
|
||||||
|
TooltipDelaySeconds: 0.25f);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
|
/// 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
|
/// written — see the class's Schema note below for how a leftover
|
||||||
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
|
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
|
||||||
/// graphical host supplies a canonical portable configuration path. Coexists
|
/// graphical host supplies a canonical portable configuration path. Coexists
|
||||||
|
|
@ -280,6 +280,38 @@ public sealed class SettingsStore
|
||||||
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
|
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
|
||||||
=> SaveSection("cameraTurning", BuildCameraTurningObject(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>
|
/// <summary>
|
||||||
/// Load per-character settings keyed by <paramref name="toonKey"/>.
|
/// Load per-character settings keyed by <paramref name="toonKey"/>.
|
||||||
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
|
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
|
||||||
|
|
@ -652,6 +684,13 @@ public sealed class SettingsStore
|
||||||
["vsync"] = d.VSync,
|
["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)
|
private static SortedDictionary<string, object> BuildCameraTurningObject(CameraTurningSettings c)
|
||||||
=> new(StringComparer.Ordinal)
|
=> new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -478,4 +478,130 @@ public class ElementReaderTests
|
||||||
|
|
||||||
Assert.Equal(0u, info.ScrollbarElementId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,71 @@ public class LayoutImporterTests
|
||||||
Assert.Null(tree.FindElement(0xBBB00001u));
|
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 ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
||||||
|
|
|
||||||
437
tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Normal file
437
tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
}
|
||||||
184
tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
Normal file
184
tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
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, bool Showable)>();
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// 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.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 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)
|
||||||
|
{
|
||||||
|
yield return root;
|
||||||
|
foreach (ElementInfo child in root.Children)
|
||||||
|
foreach (ElementInfo descendant in AllDescendants(child))
|
||||||
|
yield return descendant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -631,4 +631,57 @@ public sealed class SettingsStoreTests : System.IDisposable
|
||||||
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
|
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
|
||||||
Assert.Null(store.LoadNamedWindowLayout("missing", "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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue