Merge campaign-409-tooltips: #409 runtime-tooltip resolution fix — live-verified
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 reads the runtime m_TTText FIRST (StartTooltipAtMouse @0x00460DA3)
with authored P0x49 as fallback; the presenter read only authored text,
so every runtime-written tooltip (Options rows, checkbox bitfields, both
social pages — writers acdream already had) never showed. Fixed with
retail's resolution order + the P0x48 own-layout fallback; live-verified
on a connected client. The authored-243 population measured as entirely
chargen-resident. Deferred honestly: inventory item-name tooltips
(UIElement_UIItem::UpdateTooltip — UiItemSlot lacks the plumbing) and
#411 (hover cursor/rollover feedback, full mechanism mapped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 21:45:37 +02:00
commit 34ec397e9e
8 changed files with 487 additions and 33 deletions

View file

@ -24,6 +24,87 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
**Status:** OPEN (filed 2026-08-16 during the #409 live-failure investigation, at the
lead's scope addition; user report: "the cursor should light up when I hover over an
item in inventory. it does not")
**Severity:** LOW (cosmetic/affordance; no gameplay impact)
**Depends on:** nothing — the hover dispatch it needs is already correct (see #409's
live-failure round, which proved `UiRoot.UpdateHover` selects the right widget).
**Retail mechanism, derived from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.**
There are THREE separate hover-feedback layers, and the DAT decides which one applies:
1. **Per-element cursor**`UIElementManager::CheckCursor @0x0045ABF0`, called from
`SwitchMouseOver @0x0045B5F8` whenever the entered element changes. It takes
`m_pElementWithMouseCapture` if it `HasCursor()`, else `m_pElementLastEntered` if it
`HasCursor()`, and calls `SetCursor(elem->m_cursorDID, m_cursorHotX, m_cursorHotY, 0)`;
otherwise it restores `m_defaultCursorDID` with the "is default" flag 1.
`UIElement::HasCursor @0x00464980` is just `m_cursorDID != INVALID_DID`, and the ONLY
writer of `m_cursorDID` in the whole binary is `UIElement::SetCursor @0x0045FF50`,
whose ONLY caller is `MediaMachine::Update_Cursor @0x00465A80` — i.e. this layer is
100% authored `MediaDescCursor` state media, never game code.
**MEASURED (exhaustive raw scan of every `ElementDesc` in every `LayoutDesc`,
template roots and nested children included): exactly 101 authored cursor media,
on element types 9 (Resizebar) and 2 (Dragbar) only, using only 5 cursor DIDs —
`0x06006119`, `0x06006126`, `0x06006127`, `0x06006128`, `0x06005E66`.** Those five
are precisely what `RetailCursorCatalog.TryGetWindowControlCursor` already
hardcodes. **So NO inventory item, item list, button, or option row authors a
per-element cursor in retail, and acdream is not missing a cursor swap for them.**
What acdream IS missing here is the general seam: `UiElement.StateCursors` is parsed
(`LayoutImporter.ReadState`) and stored (`UiElement.SetStateCursors`) but has ZERO
consumers — the window move/resize cursors reach the screen through the hardcoded
catalog instead. Porting `CheckCursor` properly means driving the cursor off the
hovered element's own `StateCursors` and deleting the hardcode.
2. **Global "found" cursor**`ClientUISystem::UpdateCursorState @0x00564630` picks the
`...Found` variant of whichever cursor the current combat/target/busy mode selects,
keyed ONLY on `SmartBox::get_found_object_id() != 0`. The only writer is
`UIElement_SmartBoxWrapper::FindObject @0x004E545D` — the 3D viewport's world pick.
So this layer never fires over an inventory item either.
3. **Per-element rollover STATE**`UIElementManager::SwitchMouseOver @0x0045B560` calls
`m_pElementLastEntered->MouseOverTop(1)` on enter and `(0)` on leave.
`UIElement::MouseOverTop @0x004615D0` sets `__bitfield164` bit 0 and broadcasts
element message `0x1B`; the media machine then swaps the element to its rollover
media. `UIElement_Button::MouseOverTop @0x004721F0` and
`UIElement_Field::MouseOverTop @0x00472360` override it (the Field override is the
drag-drop accept/reject state pair, states 9/10).
**Most likely what the user is seeing.** Because layers 1 and 2 are measurably not
involved for an inventory item, "the cursor should light up" is most likely layer 3 —
the item cell's own rollover highlight. acdream's `UiButton` honors rollover
(`RolloverEnabled`, dat property `0x13`, `UiButton.cs:462` + its HoverEnter/HoverLeave
handling at `UiButton.cs:831/835`), but **`UiItemSlot` has no hover handling at all** —
no HoverEnter/HoverLeave, no rollover state. That is the concrete gap to close first.
**Open question that needs one user observation (or a retail side-by-side).** Whether
the retail behavior the user remembers is (a) the item cell highlighting, or (b) the
mouse pointer bitmap actually changing. If it is (b), the mechanism is NOT any of the
three above for a UI item and needs a fresh derivation — do not guess. Ask before
porting.
**Port plan (in dependency order).**
1. Give `UiItemSlot` a HoverEnter/HoverLeave rollover state (retail
`UIElement::MouseOverTop @0x004615D0` bit 0 + message `0x1B`), sourced from the
cell's own authored rollover media where it has one.
2. Replace `RetailCursorCatalog.TryGetWindowControlCursor`'s five hardcoded DIDs with a
real `CheckCursor @0x0045ABF0` port off `UiElement.StateCursors` + a capture-wins
precedence, driven from `UiRoot`'s existing hover-change edge (the same edge #409's
tooltip dwell already uses). This is behavior-neutral for the 101 measured elements
and removes a hardcode; it is the prerequisite for any future DAT that authors a
cursor somewhere new.
3. Only if the user confirms (b) above: derive the item-hover cursor mechanism fresh.
**Files:** `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs`
(`UpdateHover`), `src/AcDream.App/UI/RetailCursorCatalog.cs`,
`src/AcDream.App/UI/CursorFeedbackController.cs`, `src/AcDream.App/UI/UiElement.cs`
(`StateCursors`, today unconsumed), `src/AcDream.App/UI/Layout/LayoutImporter.cs`
(`ReadState`'s `MediaDescCursor` read).
---
## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center)
**Status:** OPEN
@ -107,7 +188,7 @@ horizontal `HJustify` mapping while in this code, since it shares the
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
**Status:** CODE-COMPLETE 2026-08-16, 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).
**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11 and the LIVE-FAILURE round both landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the user's full connected gate is still owed.
**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
@ -125,6 +206,63 @@ 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 LIVE-FAILURE round (user gate on build `1.0.3-tt.a`: "tooltips do not appear
anywhere except one on the paperdoll").** Root-caused, fixed, and live-verified the same
day. TWO findings, both measured, neither of them a broken hover/hit-test:
1. **The dominant root cause: the presenter read only the AUTHORED text.**
`RetailTooltipPresenter.OnTooltipShow` gated on `widget.AuthoredTooltipText`
(`P0x49`) alone. Retail's `UIElement::StartTooltipAtMouse @0x00460D70` takes the
RUNTIME `m_TTText` first (`@0x00460DA3` `StringInfo::IsValid` -> `@0x00460DAA`
verbatim copy) and only falls back to `InqProperty(0x49)` at `@0x00460DDF`.
acdream ALREADY had the runtime layer — `UiElement.GetTooltipText()`, populated by
`CharacterOptionsPageController:477`, `ChatOptionsPageController:502`,
`ConfigOptionsPageController` (5 sites), `KeyboardConfigController:477`,
`SocialAllegiancePageController:412`, `SocialFellowshipPageController:458`, and
`UiCheckboxBitfield64:216` — but nothing consulted it. **Live-DAT measured:** the
Options toggle-row checkbox (`0x2100002B` template root `0x10000218`, checkbox leaf
`0x10000219`) authors `P0x47=0x10000397 P0x48=0x21000041 P0x4B=true` and an EMPTY
`P0x49` — the popup locator and the on-bit are authored, only the text arrives at
runtime, exactly as retail's `UIOption_CheckboxBitfield64::CreateChildren
@0x00485E65` stamps its `siTooltip` array. Re-measured across every LayoutDesc: all
187 no-literal-text tooltip elements author BOTH locator ids, i.e. the whole set is
runtime-text targets. FIXED: `RetailTooltipPresenter.ResolveTooltipText` now uses
retail's order, and the `P0x48`-absent fallback to the element's own LayoutDesc
(`@0x00460E7E`, `this->m_layout->m_DID`) is ported through the new
`UiElement.SourceLayoutDid` threaded from `LayoutImporter.Build`'s new
`sourceLayoutDid` parameter. (`RowTemplateResolver`'s build delegate carries no
layout id, so social row templates leave `SourceLayoutDid` at 0 — they author
`P0x48` anyway, so no live case needs the fallback there.)
2. **The "243 showable" number was never an in-world number.** A grouped re-sweep of
the same 243 found them concentrated in CHARACTER-CREATION layouts (`0x21000038`
heritage/profession/skills/appearance/town/summary tabs, `0x21000047` attributes,
`0x21000049`/`0x2100004C` appearance+skills, `0x21000005`/`0x2100000F`/`0x21000068`
the shared appearance page, `0x21000046` heritage picks). The INVENTORY window
(`0x21000023`) and paperdoll (`0x21000024`) author exactly TWO between them:
`0x100001D6` "Drag clothing and armor here to wear them" (the doll drag mask,
`PaperdollController.DollDragMaskId`) and `0x100005BE` "When this option is chosen,
you will see explicit equipment slots instead of a portrait" (the Slots button).
**That first one IS the user's single working tooltip** — confirmed live. So the
paperdoll was never a differential against a broken mechanism; it was the only
authored-text tooltip in the panel being hovered. Reachability was also measured
and is NOT a problem: 238 of the 243 build as real, non-`ClickThrough` hover
targets (230 `UiButton`, 6 `UiScrollbar`, 2 `UiField`; the 5 misses are Type-12
prototypes the importer skips by design).
**Live verification (2026-08-16, connected `testaccount`/`+Acdream`, Release,
`ACDREAM_RETAIL_UI=1`):** hovering Options -> Character -> "Vivid Targeting Indicator"
now shows "Enable this option to apply a targeting indicator around selected objects
and monsters for better visual reference"; a temporary hover probe confirmed the hover
target is element `0x10000219` with `runtime=True`. Hovering the paperdoll still shows
"Drag clothing and armor here to wear them". Hovering an inventory ITEM still shows
nothing — that is `UIElement_UIItem::UpdateTooltip @0x004E1CB0` (retail shows the item
NAME, `"%d %s"`-prefixed when the stack is > 1), which stays deferred: acdream's
`UiItemSlot` is constructed programmatically at 6+ sites and carries neither the
`P0x47` popup locator nor a name source, so porting it is its own slice, not a
one-line seam. Register TS-85 is narrowed accordingly and now enumerates all 15
`SetTooltip` call sites split into ported / no-acdream-analog.
**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).
@ -196,19 +334,34 @@ and the per-tick `BringToFront` re-raise chain now has four rungs
`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.
**Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`) — REWRITTEN at the live-failure
round, because the original note only listed chargen surfaces and so could not
have caught the in-world gap the user's gate found.** Hover and hold still; a
small box should appear after ~0.25 s and vanish when you move to a different
control.
*In-world (this is what the live-failure fix added — check these FIRST):*
(1) Options (F11) -> **Character** tab, any checkbox row (e.g. "Vivid Targeting
Indicator") — a full help sentence. Same for the **Chat** and **Config** tabs'
rows, sliders and dropdowns. (2) Options -> Configure Keyboard, any key button.
(3) Social panel (F3/F4) -> the Allegiance/Fellowship checkboxes. (4) Inventory
-> hover the paperdoll figure ("Drag clothing and armor here to wear them") and
the "Slots" button.
*Chargen (already worked before this round):* (5) Appearance page rotate arrows
("Rotate left."/"Rotate right.") and any color swatch; (6) the hair/eyes/nose
spin arrows — longer text, should WRAP rather than run off-screen; (7) the
Heritage/Profession/Skills/Town/Summary tab buttons.
*Expected NOT to show anything yet (deferred, register TS-85):* hovering an
inventory ITEM icon. Retail shows the item name there
(`UIElement_UIItem::UpdateTooltip @0x004E1CB0`); acdream shows nothing.
Confirm also: the box sits offset down-right of the cursor (retail's +32px on
both axes), 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.
---

File diff suppressed because one or more lines are too long

View file

@ -87,17 +87,24 @@ public static class LayoutImporter
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
/// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param>
/// <param name="sourceLayoutDid">#409: the LayoutDesc DID these infos came from,
/// recorded on every built widget as <see cref="UiElement.SourceLayoutDid"/>.
/// Retail keeps the same back-pointer (<c>UIElement::m_layout</c>) and reads it in
/// <c>StartTooltipAtMouse @0x00460E7E</c> as the tooltip popup layout when the
/// element authors no <c>P0x48</c>. Zero (the default) leaves it unknown, which
/// simply declines that fallback — callers with no dat context pass nothing.</param>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
Func<UiStringInfoValue, string?>? stringResolve = null,
uint sourceLayoutDid = 0u)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId);
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
@ -112,11 +119,15 @@ public static class LayoutImporter
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve,
Dictionary<uint, UiElement> byId)
Dictionary<uint, UiElement> byId,
uint sourceLayoutDid)
{
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip
// #409: see the Build overload's own sourceLayoutDid doc comment.
w.SourceLayoutDid = sourceLayoutDid;
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
w.AuthoredInvisible = info.Invisible;
@ -152,7 +163,7 @@ public static class LayoutImporter
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is not null) w.AddChild(cw);
}
}
@ -178,7 +189,7 @@ public static class LayoutImporter
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is not null) w.AddChild(cw);
}
}
@ -206,7 +217,7 @@ public static class LayoutImporter
foreach (var child in info.Children)
{
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through
@ -397,7 +408,7 @@ public static class LayoutImporter
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve, layoutId);
}
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
@ -412,7 +423,7 @@ public static class LayoutImporter
var rootInfo = ImportInfos(dats, layoutId, rootElementId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve, layoutId);
}
// ── Inheritance resolution ────────────────────────────────────────────────

View file

@ -96,26 +96,92 @@ public sealed class RetailTooltipPresenter : IDisposable
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Retail <c>UIElement::StartTooltipAtMouse @0x00460D70</c>'s text source, in
/// retail's own order: the RUNTIME <c>m_TTText</c> first
/// (<c>@0x00460DA3</c> tests <c>StringInfo::IsValid(&amp;m_TTText)</c> and takes
/// it verbatim at <c>@0x00460DAA</c>), and only when that is empty does it fall
/// back to the AUTHORED <c>P0x49</c> property (<c>@0x00460DDF</c>
/// <c>InqProperty(0x49)</c>).
///
/// <para>
/// <c>UiElement.GetTooltipText()</c> is this port's <c>m_TTText</c>: it is what
/// every acdream analog of retail's ~15 game-code <c>UIElement::SetTooltip</c>
/// call sites already writes — the Options panel's per-row
/// <c>ID_PlayerOption_*_Help</c> strings
/// (<c>UIOption_CheckboxBitfield64::CreateChildren @0x00485E65</c>'s
/// <c>siTooltip</c> array; acdream <c>CharacterOptionsPageController</c>,
/// <c>ChatOptionsPageController</c>, <c>ConfigOptionsPageController</c>,
/// <c>UiCheckboxBitfield64</c>), the Configure-Keyboard key captions, and the
/// social pages' checkbox help. Reading only <see cref="UiElement.AuthoredTooltipText"/>
/// (as this class did before) is why NONE of those showed live: retail's
/// in-world panels author the popup LOCATOR (<c>P0x47</c>/<c>P0x48</c>) and the
/// <c>P0x4B</c> on-bit but deliberately author NO <c>P0x49</c> text, because the
/// text arrives at runtime. Live-DAT measured: the Options toggle row
/// (<c>0x2100002B</c>/<c>0x10000218</c>) authors
/// <c>P0x47=0x10000397 P0x48=0x21000041 P0x4B=true P0x49=&lt;empty&gt;</c>.
/// </para>
/// </summary>
private static string? ResolveTooltipText(UiElement widget, out bool fromRuntime)
{
string? runtime = widget.GetTooltipText();
if (!string.IsNullOrEmpty(runtime))
{
fromRuntime = true;
return runtime;
}
fromRuntime = false;
return widget.AuthoredTooltipText;
}
private void OnTooltipShow(UiElement widget)
{
RemovePopup();
if (!Enabled || !widget.AuthoredTooltipEnabled)
if (!Enabled)
return;
if (string.IsNullOrEmpty(widget.AuthoredTooltipText))
string? tooltipText = ResolveTooltipText(widget, out bool fromRuntime);
if (string.IsNullOrEmpty(tooltipText))
return;
if (widget.AuthoredTooltipLayoutDid == 0u || widget.AuthoredTooltipRootElementId == 0u)
// The P0x4B TooltipOn bit (UIElement::MouseHover @0x0046254C reads
// __bitfield164 bit 5). Retail's game-code SetTooltip sites do not rely on
// the authored bit — each one SETS it in the same breath as the text:
// UIElement_UIItem::UpdateTooltip @0x004E1D5E, gmPaperDollUI::
// UpdateItemSlotTooltip @0x004A52F4, gmSpellcastingUI::UpdateEndowmentIcon
// @0x004C63AC, SpellCastSubMenu::UpdateFromPlayerModule @0x004C67ED,
// gmSpellcastingUI::UpdateCastButtonTooltip @0x004C7000, SpellCastSubMenu::
// AddFavorite @0x004C7218, gmRadarUI::DrawObjects @0x004D9617, and
// UIElement_Text::RecalculateTruncation @0x00467076 — all `|= 0x20`, each
// paired with a `&= ~0x20` on the clearing path. So a widget carrying
// runtime tooltip text is tooltip-on by construction; only the AUTHORED-
// text path consults the authored bit.
if (!fromRuntime && !widget.AuthoredTooltipEnabled)
return;
if (widget.AuthoredTooltipRootElementId == 0u)
return;
// StartTooltipAtMouse @0x00460E6B reads P0x48 and, when it is absent
// (@0x00460E7E), substitutes the element's OWN LayoutDesc DID
// (this->m_layout->m_DID) before dispatching to StartTooltip. Only when
// BOTH are absent (@0x00460E91) does it give up.
uint layoutDid = widget.AuthoredTooltipLayoutDid != 0u
? widget.AuthoredTooltipLayoutDid
: widget.SourceLayoutDid;
if (layoutDid == 0u)
return;
ImportedLayout? layout;
try
{
layout = _createLayout(widget.AuthoredTooltipLayoutDid, widget.AuthoredTooltipRootElementId);
layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId);
}
catch (Exception error)
{
Console.WriteLine(
$"[UI] #409 tooltip popup layout=0x{widget.AuthoredTooltipLayoutDid:X8} "
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
return;
}
@ -150,7 +216,7 @@ public sealed class RetailTooltipPresenter : IDisposable
text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
ApplyTooltipText(root, text, tooltipText!);
SetClickThroughRecursive(root);
PositionAtMouse(root);

View file

@ -2506,7 +2506,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
strings.Resolve,
templateLayoutId).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.CharacterOptionsPageController.Bindings(
@ -2567,7 +2568,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
strings.Resolve,
templateLayoutId).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.ChatOptionsPageController.Bindings(
@ -2613,7 +2615,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
strings.Resolve,
templateLayoutId).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.ConfigOptionsPageController.Bindings(
@ -2782,7 +2785,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
strings.Resolve,
templateLayoutId).Root;
}
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),

View file

@ -107,6 +107,20 @@ public abstract class UiElement
/// </summary>
public uint AuthoredTooltipLayoutDid { get; internal set; }
/// <summary>
/// #409 (live-failure round): the DID of the <c>LayoutDesc</c> this widget
/// was imported from, stamped by <c>LayoutImporter.Import</c>'s dat shell.
/// Retail's <c>UIElement::StartTooltipAtMouse @0x00460D70</c> falls back to
/// exactly this — <c>this->m_layout->m_DID</c> (<c>@0x00460E7E</c>) — when
/// the hovered element authors a tooltip popup ROOT (<c>P0x47</c>) but no
/// popup LAYOUT (<c>P0x48</c>), i.e. the popup root lives in the element's
/// own layout. Zero when the widget came through the pure
/// <c>LayoutImporter.Build</c> layer (which has no dat context) or was
/// constructed directly; the presenter then behaves exactly as it did
/// before this field existed.
/// </summary>
public uint SourceLayoutDid { get; internal set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
/// property <c>0x4A</c>). Meaningful only when read off a tooltip

View file

@ -434,4 +434,159 @@ public sealed class RetailTooltipPresenterTests
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.Equal(40f, popup.Width); // clamped, not the larger natural measured size
}
// ── #409 live-failure round: the RUNTIME text family ────────────────────
/// <summary>A hover target that carries retail's runtime <c>m_TTText</c>
/// (this port's <see cref="UiElement.GetTooltipText"/>) instead of an
/// authored <c>P0x49</c> — the exact shape of every Options-panel row, the
/// Configure-Keyboard key buttons, and the social pages' checkboxes.</summary>
private sealed class RuntimeTextTarget : UiElement
{
public string? Runtime { get; set; }
public override string? GetTooltipText() => Runtime;
}
private static RuntimeTextTarget AddRuntimeTextTarget(
UiRoot root, string? runtime, bool authoredOn = true,
uint layoutDid = PopupLayoutDid, uint sourceLayoutDid = 0u)
{
var target = new RuntimeTextTarget
{
Left = 100, Top = 100, Width = 40, Height = 20,
Runtime = runtime,
AuthoredTooltipEnabled = authoredOn,
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = layoutDid,
SourceLayoutDid = sourceLayoutDid,
};
root.AddChild(target);
return target;
}
private static void HoverAndDwell(UiRoot root)
{
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
}
[Fact]
public void RuntimeText_ShowsEvenWithNoAuthoredP0x49()
{
// THE live-failure root cause. Retail StartTooltipAtMouse @0x00460DA3
// takes m_TTText verbatim when it is valid and only falls back to the
// authored P0x49 at @0x00460DDF. Live-DAT measured: the Options toggle
// row's checkbox (0x2100002B/0x10000219) authors P0x47/P0x48/P0x4B but
// NO P0x49 — its help text arrives at runtime, exactly like retail's
// UIOption_CheckboxBitfield64::CreateChildren @0x00485E65 siTooltip.
var (root, _, requests) = CreateHarness();
var target = AddRuntimeTextTarget(
root, "When this option is chosen, you will always appear as offline.");
int childrenBefore = root.Children.Count;
HoverAndDwell(root);
Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId));
Assert.Equal(childrenBefore + 1, root.Children.Count);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
UiText text = Assert.IsType<UiText>(FindById(popup, TextChildId));
Assert.Contains(
"appear as offline",
string.Join(' ', text.LinesProvider!().Select(l => l.Text)));
}
[Fact]
public void RuntimeText_WinsOverTheAuthoredP0x49()
{
// Precedence, not merge: @0x00460DA3's IsValid(m_TTText) branch skips
// the InqProperty(0x49) read entirely.
var (root, _, _) = CreateHarness();
var target = AddRuntimeTextTarget(root, "runtime wins");
target.AuthoredTooltipText = "authored loses";
HoverAndDwell(root);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
UiText text = Assert.IsType<UiText>(FindById(popup, TextChildId));
string shown = string.Join(' ', text.LinesProvider!().Select(l => l.Text));
Assert.Contains("runtime wins", shown);
Assert.DoesNotContain("authored loses", shown);
}
[Fact]
public void RuntimeText_DoesNotNeedTheAuthoredTooltipOnBit()
{
// Every game-code SetTooltip site sets the bit itself in the same
// breath as the text (UIElement_UIItem::UpdateTooltip @0x004E1D5E and
// seven siblings, all `__bitfield164 |= 0x20`), so runtime text is
// tooltip-on by construction. The AUTHORED-text path still consults
// the authored bit — pinned by the next test.
var (root, _, requests) = CreateHarness();
AddRuntimeTextTarget(root, "runtime text, authored bit off", authoredOn: false);
HoverAndDwell(root);
Assert.Single(requests);
}
[Fact]
public void AuthoredText_StillRequiresTheAuthoredTooltipOnBit()
{
var (root, _, requests) = CreateHarness();
var target = AddRuntimeTextTarget(root, runtime: null, authoredOn: false);
target.AuthoredTooltipText = "authored, but P0x4B is off";
HoverAndDwell(root);
Assert.Empty(requests);
}
[Fact]
public void MissingP0x48_FallsBackToTheElementsOwnSourceLayout()
{
// StartTooltipAtMouse @0x00460E7E: when GetAttribute_DataID(0x48)
// yields INVALID, retail substitutes this->m_layout->m_DID before
// dispatching, and only gives up when both are absent (@0x00460E91).
var (root, _, requests) = CreateHarness();
AddRuntimeTextTarget(
root, "no P0x48 authored", layoutDid: 0u, sourceLayoutDid: 0x21000099u);
HoverAndDwell(root);
Assert.Single(requests, r => r == (0x21000099u, PopupRootId));
}
[Fact]
public void MissingP0x48_AndNoSourceLayout_ShowsNothing()
{
var (root, _, requests) = CreateHarness();
AddRuntimeTextTarget(root, "nowhere to build the popup", layoutDid: 0u);
HoverAndDwell(root);
Assert.Empty(requests);
}
[Fact]
public void MissingP0x47_ShowsNothing_EvenWithRuntimeText()
{
// The popup ROOT id is retail's hard gate (@0x00460E44's
// GetAttribute_Enum(0x47) short-circuits the whole function).
var (root, _, requests) = CreateHarness();
var target = AddRuntimeTextTarget(root, "text but no popup root");
target.AuthoredTooltipRootElementId = 0u;
HoverAndDwell(root);
Assert.Empty(requests);
}
private static UiElement? FindById(UiElement root, uint datElementId)
{
if (root.DatElementId == datElementId) return root;
foreach (UiElement child in root.Children)
if (FindById(child, datElementId) is { } found) return found;
return null;
}
}

View file

@ -174,6 +174,57 @@ public sealed class TooltipLiveDatTests
$"expected at least 200 fully showable elements, found {withProperties.Count(f => f.Showable)}.");
}
/// <summary>
/// #409 live-failure round, the pin that protects the runtime-text fix.
/// The Options panel's authored toggle-row TEMPLATE (LayoutDesc
/// <c>0x2100002B</c>, row root <c>0x10000218</c>, checkbox leaf
/// <c>0x10000219</c>) carries the full tooltip POPUP LOCATOR
/// (<c>P0x47</c>/<c>P0x48</c>) and the <c>P0x4B</c> on-bit, but authors NO
/// <c>P0x49</c> text — its help string arrives at runtime, exactly as
/// retail's <c>UIOption_CheckboxBitfield64::CreateChildren @0x00485E65</c>
/// stamps its <c>siTooltip</c> array. This is why reading only
/// <c>AuthoredTooltipText</c> showed nothing anywhere in-world; see
/// <c>RetailTooltipPresenter.ResolveTooltipText</c>.
///
/// <para>The row template is template-list referenced, so
/// <see cref="LayoutImporter.ImportInfos"/>'s client-wide walk deliberately
/// filters it out (#375) — it has to be imported by its own root id.</para>
/// </summary>
[InstalledDatFact]
public void OptionsToggleRowTemplate_AuthorsThePopupLocatorButNoLiteralText()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
ImportedLayout? row = LayoutImporter.Import(
dats, OptionsPanelLayoutId, OptionsToggleRowTemplateId, _ => (0u, 0, 0), null);
Assert.NotNull(row);
UiElement checkbox = Assert.Single(
AllWidgets(row!.Root), w => w.DatElementId == OptionsToggleCheckboxId);
Assert.Equal(0x10000397u, checkbox.AuthoredTooltipRootElementId); // P0x47
Assert.Equal(TooltipCatalogLayoutId, checkbox.AuthoredTooltipLayoutDid); // P0x48
Assert.True(checkbox.AuthoredTooltipEnabled); // P0x4B
Assert.True(string.IsNullOrEmpty(checkbox.AuthoredTooltipText)); // no P0x49
// ...and it is a real hover target, so UiRoot.UpdateHover can select it.
Assert.False(checkbox.ClickThrough);
// StartTooltipAtMouse @0x00460E7E's fallback source, stamped by the
// importer's dat shell.
Assert.Equal(OptionsPanelLayoutId, checkbox.SourceLayoutDid);
}
private const uint OptionsPanelLayoutId = 0x2100002Bu;
private const uint OptionsToggleRowTemplateId = 0x10000218u;
private const uint OptionsToggleCheckboxId = 0x10000219u;
private static IEnumerable<UiElement> AllWidgets(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in AllWidgets(child))
yield return descendant;
}
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
{
yield return root;