fix(ui): #409 live-failure round — tooltips read the RUNTIME text first

User gate on 1.0.3-tt.a: tooltips appeared NOWHERE in-world except one on
the paperdoll. Root-caused, fixed, and live-verified against a connected
client the same day. Two findings, both measured; neither is a broken
hover/hit-test.

1. DOMINANT ROOT CAUSE — RetailTooltipPresenter.OnTooltipShow gated on
   widget.AuthoredTooltipText (P0x49) alone. Retail's
   UIElement::StartTooltipAtMouse @0x00460D70 takes the RUNTIME m_TTText
   first (@0x00460DA3 IsValid -> @0x00460DAA verbatim) and only falls back
   to InqProperty(0x49) at @0x00460DDF. acdream ALREADY had the runtime
   layer — UiElement.GetTooltipText(), written by the Options/Chat/Config
   page controllers, KeyboardConfigController, the social pages and
   UiCheckboxBitfield64 — but nothing read it.

   Live-DAT measured: the Options toggle-row checkbox (0x2100002B template
   root 0x10000218, 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
   UIOption_CheckboxBitfield64::CreateChildren @0x00485E65 stamps its
   siTooltip array. Re-measured client-wide: ALL 187 no-literal-text
   tooltip elements author both locator ids, i.e. the whole set is
   runtime-text targets.

   Fixed by ResolveTooltipText (retail's order), plus:
   - the P0x4B gate now applies only to the AUTHORED-text path, because
     retail's eight game-code SetTooltip sites set the on-bit themselves
     (__bitfield164 |= 0x20 at @0x004E1D5E/@0x004A52F4/@0x004C63AC/
     @0x004C67ED/@0x004C7000/@0x004C7218/@0x004D9617/@0x00467076);
   - the P0x48-absent fallback to the element's own LayoutDesc
     (@0x00460E7E, this->m_layout->m_DID) is ported via the new
     UiElement.SourceLayoutDid, threaded from LayoutImporter.Build's new
     sourceLayoutDid parameter and passed by Import + the four template
     resolvers.

2. THE "243 SHOWABLE" NUMBER WAS NEVER AN IN-WORLD NUMBER. Grouped
   re-sweep: all 243 sit in CHARACTER-CREATION layouts. 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) and 0x100005BE (the Slots button). The first IS
   the user's single working tooltip, so the paperdoll was never a
   differential against a broken mechanism. Reachability was measured and
   is fine: 238/243 build as real non-ClickThrough hover targets.

LIVE VERIFICATION (connected testaccount/+Acdream, Release,
ACDREAM_RETAIL_UI=1): Options -> Character -> "Vivid Targeting Indicator"
now shows its full ID_PlayerOption_*_Help sentence; a temporary hover probe
confirmed the hover target is element 0x10000219 with runtime=True. The
paperdoll tooltip still shows. 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:
UiItemSlot is constructed programmatically at 6+ sites and carries neither
the P0x47 locator nor a name source, so it is its own slice.

Bookkeeping: register TS-85 narrowed (m_TTText READ side now ported; the
row now enumerates all 15 SetTooltip call sites split into ported vs
no-acdream-analog). #409's gate note rewritten to lead with the in-world
surfaces — the old note listed only chargen, which is why it could not
have caught this. Filed #411 for the hover-cursor scope addition: an
exhaustive raw scan of every ElementDesc found only 101 authored
MediaDescCursor entries, all on Dragbar/Resizebar with the 5 DIDs
RetailCursorCatalog already hardcodes, so retail has NO per-element cursor
for inventory items; the likely mechanism is the rollover STATE
(UIElement::MouseOverTop @0x004615D0) that UiItemSlot lacks entirely.

Gates: Release build 0 errors; App suite (live-DAT env) 5424/5421 passed/3
skips (was 5416/5413/3, +8 new tests); Runtime 1735/0; full solution (no
env) 14,631/14,561 passed/70 skipped/0 failed (was 14,623/14,554/69).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 21:44:44 +02:00
parent 2719782dc0
commit 5f9ca18155
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. - 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. - 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) ## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center)
**Status:** OPEN **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) ## #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) **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 **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 TS-85 for the honest scope of what's missing there), superseding the
original "~253" estimate. 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 **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 port above returned architectural PASS-with-findings / retail-fidelity FAIL
with twelve findings; F1-F11 landed in one fix commit (F12 was info-only). 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 `RetailDialogFactory`, `RetailTooltipPresenter`) — bounded and enumerable
today, but a design smell worth flagging. today, but a design smell worth flagging.
**Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`):** hover the mouse over any **Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`) — REWRITTEN at the live-failure
of these and hold still — a small tooltip box should appear after a brief round, because the original note only listed chargen surfaces and so could not
pause (~0.25 s, matching retail's registered default) and disappear when have caught the in-world gap the user's gate found.** Hover and hold still; a
you move to a different control: (1) chargen Appearance page — the rotate small box should appear after ~0.25 s and vanish when you move to a different
arrows beside the preview ("Rotate left."/"Rotate right.") and any color control.
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 *In-world (this is what the live-failure fix added — check these FIRST):*
multiple lines, not run off-screen); (3) any other screen with a tooltip (1) Options (F11) -> **Character** tab, any checkbox row (e.g. "Vivid Targeting
you recall from retail. Confirm: the box sits right at the cursor (not Indicator") — a full help sentence. Same for the **Chat** and **Config** tabs'
offset), never runs off the edge of the window even near a corner, and rows, sliders and dropdowns. (2) Options -> Configure Keyboard, any key button.
disappears on its own after ~10 s if you hold still without moving away. (3) Social panel (F3/F4) -> the Allegiance/Fellowship checkboxes. (4) Inventory
No click-to-dismiss is expected — only moving off the control, or a very -> hover the paperdoll figure ("Drag clothing and armor here to wear them") and
long hold, closes it. 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. /// 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 /// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param> /// 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( public static ImportedLayout Build(
ElementInfo rootInfo, ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null, Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null) Func<UiStringInfoValue, string?>? stringResolve = null,
uint sourceLayoutDid = 0u)
{ {
var byId = new Dictionary<uint, UiElement>(); var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic // 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. // 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) 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."); 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, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve, Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve, Func<UiStringInfoValue, string?>? stringResolve,
Dictionary<uint, UiElement> byId) Dictionary<uint, UiElement> byId,
uint sourceLayoutDid)
{ {
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve); var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip 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 // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// 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;
@ -152,7 +163,7 @@ public static class LayoutImporter
{ {
foreach (var child in info.Children) 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); if (cw is not null) w.AddChild(cw);
} }
} }
@ -178,7 +189,7 @@ public static class LayoutImporter
foreach (var child in info.Children) foreach (var child in info.Children)
{ {
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter 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); if (cw is not null) w.AddChild(cw);
} }
} }
@ -206,7 +217,7 @@ public static class LayoutImporter
foreach (var child in info.Children) foreach (var child in info.Children)
{ {
if (child.StateMedia.Count == 0) continue; 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; if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through // of AuthoredInvisible, scoped to children reached through
@ -397,7 +408,7 @@ public static class LayoutImporter
var rootInfo = ImportInfos(dats, layoutId); var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null; if (rootInfo is null) return null;
var strings = new DatStringResolver(dats); 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> /// <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); var rootInfo = ImportInfos(dats, layoutId, rootElementId);
if (rootInfo is null) return null; if (rootInfo is null) return null;
var strings = new DatStringResolver(dats); var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve); return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve, layoutId);
} }
// ── Inheritance resolution ──────────────────────────────────────────────── // ── Inheritance resolution ────────────────────────────────────────────────

View file

@ -96,26 +96,92 @@ public sealed class RetailTooltipPresenter : IDisposable
/// </summary> /// </summary>
public bool Enabled { get; set; } = true; 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) private void OnTooltipShow(UiElement widget)
{ {
RemovePopup(); RemovePopup();
if (!Enabled || !widget.AuthoredTooltipEnabled) if (!Enabled)
return; return;
if (string.IsNullOrEmpty(widget.AuthoredTooltipText))
string? tooltipText = ResolveTooltipText(widget, out bool fromRuntime);
if (string.IsNullOrEmpty(tooltipText))
return; 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; return;
ImportedLayout? layout; ImportedLayout? layout;
try try
{ {
layout = _createLayout(widget.AuthoredTooltipLayoutDid, widget.AuthoredTooltipRootElementId); layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId);
} }
catch (Exception error) catch (Exception error)
{ {
Console.WriteLine( 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}"); + $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
return; return;
} }
@ -150,7 +216,7 @@ public sealed class RetailTooltipPresenter : IDisposable
text.LayoutPolicy = null; text.LayoutPolicy = null;
text.Anchors = AnchorEdges.None; text.Anchors = AnchorEdges.None;
ApplyTooltipText(root, text, widget.AuthoredTooltipText!); ApplyTooltipText(root, text, tooltipText!);
SetClickThroughRecursive(root); SetClickThroughRecursive(root);
PositionAtMouse(root); PositionAtMouse(root);

View file

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

View file

@ -107,6 +107,20 @@ public abstract class UiElement
/// </summary> /// </summary>
public uint AuthoredTooltipLayoutDid { get; internal set; } 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> /// <summary>
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat /// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
/// property <c>0x4A</c>). Meaningful only when read off a tooltip /// 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)); UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.Equal(40f, popup.Width); // clamped, not the larger natural measured size 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)}."); $"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) private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
{ {
yield return root; yield return root;