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>
592 lines
23 KiB
C#
592 lines
23 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// #409 (client-wide retail tooltip system) fixture coverage for
|
|
/// <see cref="RetailTooltipPresenter"/> and its <see cref="UiRoot"/> hover-
|
|
/// timer wiring. Builds synthetic popup layouts in-memory (no dats) via
|
|
/// <see cref="LayoutImporter.BuildFromInfos"/> — see
|
|
/// <see cref="TooltipLiveDatTests"/> for the installed-DAT structural pins.
|
|
/// </summary>
|
|
public sealed class RetailTooltipPresenterTests
|
|
{
|
|
/// <summary>Minimal concrete hover target — plain <see cref="UiElement"/>
|
|
/// with default (false) ClickThrough, so it is hit-testable.</summary>
|
|
private sealed class HoverTarget : UiElement;
|
|
|
|
private const uint PopupRootId = 0x900u;
|
|
private const uint TextChildId = 0x901u;
|
|
private const uint PopupLayoutDid = 0x21000041u;
|
|
|
|
/// <summary>Builds a fresh 30x30/26x26 popup — the exact live-DAT-probed
|
|
/// shape (four-piece bevel frame around one Type-12 text child) — every
|
|
/// call, mirroring retail's own fresh-instance-per-show behaviour.</summary>
|
|
private static ImportedLayout BuildPopup()
|
|
{
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
};
|
|
var textInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
|
|
};
|
|
return LayoutImporter.BuildFromInfos(
|
|
rootInfo, [textInfo], _ => (0u, 0, 0), null);
|
|
}
|
|
|
|
private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests)
|
|
CreateHarness()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var requests = new List<(uint LayoutDid, uint RootElementId)>();
|
|
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
|
|
{
|
|
requests.Add((layoutDid, rootElementId));
|
|
return BuildPopup();
|
|
});
|
|
return (root, presenter, requests);
|
|
}
|
|
|
|
private static HoverTarget AddFullyAuthoredTarget(UiRoot root, string text = "Rotate left.")
|
|
{
|
|
var target = new HoverTarget
|
|
{
|
|
Left = 100, Top = 100, Width = 40, Height = 20,
|
|
AuthoredTooltipEnabled = true,
|
|
AuthoredTooltipText = text,
|
|
AuthoredTooltipRootElementId = PopupRootId,
|
|
AuthoredTooltipLayoutDid = PopupLayoutDid,
|
|
};
|
|
root.AddChild(target);
|
|
return target;
|
|
}
|
|
|
|
[Fact]
|
|
public void NoTooltipShows_WhenPropertiesAbsent()
|
|
{
|
|
// Regression pin: an element that authors NONE of the five tooltip
|
|
// properties must never produce a popup, even after the dwell delay
|
|
// and the mouse resting on it.
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
|
|
root.AddChild(target);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DelayThenShow_MountsPopupOnlyAfterTheDwellDelay()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
Assert.Equal(childrenBefore, root.Children.Count); // not yet — dwell hasn't elapsed
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs - 1);
|
|
Assert.Equal(childrenBefore, root.Children.Count); // still one ms short
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBefore + 1, root.Children.Count); // popup mounted
|
|
Assert.Single(requests, r => r == (PopupLayoutDid, PopupRootId));
|
|
}
|
|
|
|
[Fact]
|
|
public void GlobalEnableGateOff_SuppressesPresentation_ButTheDwellTimerStillFires()
|
|
{
|
|
// Retail gates the POPUP at UIElement::MouseHover, not the dwell
|
|
// timer itself (UIElementManager::CheckTooltip has no m_tooltipEnable
|
|
// check) — the C# TooltipShow event must still fire; only the
|
|
// presenter's own decision to build something is suppressed.
|
|
var (root, presenter, requests) = CreateHarness();
|
|
presenter.Enabled = false;
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
bool eventFired = false;
|
|
root.TooltipShow += _ => eventFired = true;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.True(eventFired);
|
|
Assert.Empty(requests);
|
|
Assert.Equal(childrenBefore, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void WidgetOwnTooltipDisabled_SuppressesPresentation()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipEnabled = false;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingText_SuppressesPresentation()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipText = null;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Empty(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void DismissesOnHoverTargetChange()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
|
|
root.AddChild(other);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
// Retail UIElementManager::SwitchMouseOver @0x0045B560: hovering a
|
|
// DIFFERENT element tears down the active tooltip immediately.
|
|
root.OnMouseMove(410, 410);
|
|
Assert.Equal(childrenBeforeShow, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DismissesWhenTheOwnerElementIsRemoved()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
// Retail UIElementManager::DeletingElement @0x0045E520: the tooltip's
|
|
// owner going away tears the popup down too.
|
|
root.RemoveChild(target);
|
|
Assert.Equal(childrenBeforeShow - 1, root.Children.Count); // target removed, popup removed
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoHidesAfterTheDurationElapses()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count);
|
|
|
|
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDurationMs);
|
|
Assert.Equal(childrenBeforeShow, root.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerElementDelayOverride_ReplacesTheGlobalDelay()
|
|
{
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
target.AuthoredTooltipDelaySeconds = 0f; // the live-DAT-probed override value
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0); // the very next tick after hover starts — no dwell wait
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void PositionClampsToStayFullyOnTheDisplay()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
// Small canvas + a hover point near the bottom-right corner, so the
|
|
// 30x30 popup would overflow both edges without the clamp.
|
|
root.Width = 40f;
|
|
root.Height = 40f;
|
|
var target = new HoverTarget
|
|
{
|
|
Left = 0, Top = 0, Width = 40, Height = 40,
|
|
AuthoredTooltipEnabled = true,
|
|
AuthoredTooltipText = "hi",
|
|
AuthoredTooltipRootElementId = PopupRootId,
|
|
AuthoredTooltipLayoutDid = PopupLayoutDid,
|
|
};
|
|
root.AddChild(target);
|
|
|
|
root.OnMouseMove(38, 38);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.True(popup.Left + popup.Width <= 40f, $"popup right edge {popup.Left + popup.Width} exceeds canvas width 40");
|
|
Assert.True(popup.Top + popup.Height <= 40f, $"popup bottom edge {popup.Top + popup.Height} exceeds canvas height 40");
|
|
Assert.True(popup.Left >= 0f);
|
|
Assert.True(popup.Top >= 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoResizesTheRootByTheMeasuredTextDelta()
|
|
{
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(
|
|
root, text: "This is a much longer tooltip than the authored placeholder.");
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.True(popup.Width > 30f, $"expected the popup to grow past its authored 30px width, got {popup.Width}");
|
|
}
|
|
|
|
// ── 2026-08-16 review fix round (F1-F11) pins ──────────────────────
|
|
|
|
[Fact]
|
|
public void F1_PositionsAtMouse_OffsetBy32PixelsOnBothAxes()
|
|
{
|
|
// UIElementManager::StartTooltip @0x00459700 adds a 32px (0x20)
|
|
// offset on BOTH axes before clamping (@0x00459739/@0x00459747) —
|
|
// the popup must not land flush at the cursor.
|
|
var (root, _, _) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.Equal(142f, popup.Left); // 110 + 32
|
|
Assert.Equal(142f, popup.Top); // 110 + 32
|
|
}
|
|
|
|
[Fact]
|
|
public void F2_MouseMoveWithinTheSameWidget_ResetsTheDwellClock()
|
|
{
|
|
// Retail's dwell timer anchors to mouse-IDLE, not hover-enter —
|
|
// UIElementManager::MouseMoveHandler @0x0045E710 stamps
|
|
// m_lastMouseMoveTime on EVERY move (@0x0045e729/@0x0045e736);
|
|
// CheckTooltip @0x0045B6E0 (@0x0045b747) compares against that.
|
|
// Jiggling the mouse within the SAME widget must keep pushing the
|
|
// deadline out, not leave the original hover-enter time in place.
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
|
|
root.OnMouseMove(110, 110); // hover starts at nowMs=0
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs - 10); // nowMs=240, 10ms short
|
|
Assert.Empty(requests);
|
|
|
|
// Jiggle within the same widget at nowMs=240 — resets the deadline.
|
|
root.OnMouseMove(111, 111);
|
|
root.Tick(0.016, root.TooltipDelayMs); // nowMs=250 — the OLD deadline
|
|
Assert.Empty(requests); // must NOT have fired yet if the clock reset
|
|
|
|
root.Tick(0.016, (root.TooltipDelayMs - 10) + root.TooltipDelayMs); // nowMs=490 (240+250)
|
|
Assert.Single(requests);
|
|
}
|
|
|
|
[Fact]
|
|
public void F5_TextChildResolvesToSomethingOtherThanUiText_NeverMounts()
|
|
{
|
|
// Retail requires GetChildRecursive to resolve AND DynamicCast to
|
|
// UIElement_Text (type 0xc) before StartTooltip @0x0045DE90's
|
|
// positioning/show half ever runs (@0x0045df59/@0x0045df65/
|
|
// @0x0045df6f) — a text-child id that resolves to something else
|
|
// must produce NO popup at all, not an empty unsized bevel.
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
};
|
|
var notTextInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 3, X = 2, Y = 2, Width = 26, Height = 26, // type 3, NOT 12 -> UiDatElement
|
|
};
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var requests = new List<(uint, uint)>();
|
|
var presenter = new RetailTooltipPresenter(root, (layoutDid, rootElementId) =>
|
|
{
|
|
requests.Add((layoutDid, rootElementId));
|
|
return LayoutImporter.BuildFromInfos(rootInfo, [notTextInfo], _ => (0u, 0, 0), null);
|
|
});
|
|
AddFullyAuthoredTarget(root);
|
|
int childrenBefore = root.Children.Count;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
Assert.Single(requests); // the layout WAS built...
|
|
Assert.Equal(childrenBefore, root.Children.Count); // ...but never mounted
|
|
}
|
|
|
|
[Fact]
|
|
public void F6_MouseCaptureElsewhere_SuppressesTheDwellArm_UntilCaptureReleases()
|
|
{
|
|
// CheckTooltip @0x0045B6E0's arm branch is gated
|
|
// `m_pElementWithMouseCapture == 0` (@0x0045b715) — a widget
|
|
// hovered before a drag/resize/scrollbar-thumb capture began must
|
|
// not pop a tooltip mid-gesture.
|
|
var (root, _, requests) = CreateHarness();
|
|
AddFullyAuthoredTarget(root);
|
|
var other = new HoverTarget { Left = 400, Top = 400, Width = 40, Height = 20 };
|
|
root.AddChild(other);
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
|
|
root.SetCapture(other);
|
|
root.Tick(0.016, root.TooltipDelayMs + 50);
|
|
Assert.Empty(requests); // captured elsewhere — must not arm
|
|
|
|
root.ReleaseCapture(); // restarts the idle deadline at nowMs=300
|
|
root.Tick(0.016, root.TooltipDelayMs + 50 + root.TooltipDelayMs + 50);
|
|
Assert.Single(requests); // now arms normally
|
|
}
|
|
|
|
[Fact]
|
|
public void F7_ReleaseCaptureWhileATooltipIsAlreadyShown_DoesNotHideAndReshowIt()
|
|
{
|
|
// ReleaseMouseCapture @0x0045D2B0 touches ONLY the idle timestamp
|
|
// (m_lastMouseMoveTime), never m_bHoverStarted — a mouse-up while a
|
|
// tooltip is already up must leave it up, not clear-then-re-fire it
|
|
// 250ms later without ever going through TooltipHide.
|
|
var (root, _, requests) = CreateHarness();
|
|
var target = AddFullyAuthoredTarget(root);
|
|
int childrenBeforeShow = root.Children.Count;
|
|
bool hideFired = false;
|
|
root.TooltipHide += _ => hideFired = true;
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // tooltip showing
|
|
Assert.Single(requests);
|
|
|
|
root.SetCapture(target);
|
|
root.ReleaseCapture(); // restarts the idle deadline at nowMs=TooltipDelayMs
|
|
|
|
// Advance PAST where the buggy old code's re-armed deadline would
|
|
// land (TooltipDelayMs + TooltipDelayMs) — the bug reset
|
|
// _tooltipFired=false here, so the dwell-arm branch would refire
|
|
// OnTooltipShow (RemovePopup + rebuild) a full delay later, even
|
|
// though nothing about the hover ever changed.
|
|
root.Tick(0.016, root.TooltipDelayMs + root.TooltipDelayMs + 10);
|
|
Assert.False(hideFired, "tooltip must not hide on a mere capture release");
|
|
Assert.Equal(childrenBeforeShow + 1, root.Children.Count); // still showing
|
|
Assert.Single(requests); // still exactly one OnTooltipShow call, not re-fired
|
|
}
|
|
|
|
[Fact]
|
|
public void F8_AutoResizeAppliesTheAuthoredMaxWidthClamp()
|
|
{
|
|
// UIElement::ResizeTo @0x00463C30 clamps the auto-grown size
|
|
// against P0x3C/0x3D/0x3E/0x3F BEFORE assigning it.
|
|
var rootInfo = new ElementInfo
|
|
{
|
|
Id = PopupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30,
|
|
TooltipTextChildElementId = TextChildId,
|
|
MaxWidth = 40, // P0x3D
|
|
};
|
|
var textInfo = new ElementInfo
|
|
{
|
|
Id = TextChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26,
|
|
};
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var presenter = new RetailTooltipPresenter(root, (_, _) =>
|
|
LayoutImporter.BuildFromInfos(rootInfo, [textInfo], _ => (0u, 0, 0), null));
|
|
var target = AddFullyAuthoredTarget(
|
|
root,
|
|
text: "This is a much longer tooltip than the authored placeholder, long "
|
|
+ "enough to want to grow well past forty pixels wide.");
|
|
|
|
root.OnMouseMove(110, 110);
|
|
root.Tick(0.016, 0);
|
|
root.Tick(0.016, root.TooltipDelayMs);
|
|
|
|
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
|
|
Assert.Equal(40f, popup.Width); // clamped, not the larger natural measured size
|
|
}
|
|
|
|
// ── #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;
|
|
}
|
|
}
|