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

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