fix(ui): morning gate — world tooltips ride retail's mouse-idle dwell, not the found edge

User finding 1 (side-by-side vs retail): our world-object tooltips popped
the instant the found object changed; retail's "lag". The night round's
derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the
notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is
inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and
m_dragElement is a real, distinct PDB field in acclient.h's
UIElementManager (separate from the m_pTooltipElement family), so the
immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely
STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and
the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0
mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d)
since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler
@0x0045e736). Found swaps under an IDLE mouse replace the popup the same
frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip
@0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry
(@0x0045b78a) requires a fresh mouse move before re-arming
(SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered).

Port: UiRoot gains the unconditional last-mouse-move stamp
(m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are
deliberately conditional) exposed as MouseIdleMs/NowMs;
RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the
notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/
@0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts
via the CheckTooltip dwell block (no-capture gate @0x0045b715,
m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate
branch faithfully bypasses). Session reset also forgets the staged text.

Tests: the world-hover fixture section rewritten to the corrected model —
found edge stages but never mounts before the dwell; a continuously
moving mouse never mounts until it rests; idle found-swap replaces
same-frame without stacking; duration auto-hide needs a move + fresh
dwell to remount; drag-in-progress mounts immediately. 38/38 pass.

Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired
(no dwell)" conclusion is superseded by the user's retail evidence and
the m_dragElement branch read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 08:49:11 +02:00
parent e316e190cb
commit 9d9280a069
5 changed files with 444 additions and 108 deletions

View file

@ -630,11 +630,28 @@ called out separately.
own `Label`. own `Label`.
2. **World-object hover tooltip (NPCs, players, signs, chests, portals) — 2. **World-object hover tooltip (NPCs, players, signs, chests, portals) —
SHIPPED.** NOT the UI-element dwell-timer path — retail's mechanism is SHIPPED; TIMING CORRECTED at the 2026-08-17 morning gate round.** Retail's
mechanism is
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`, `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`,
fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620` fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620`
using the current mouse position regardless of input focus. It fires using the current mouse position regardless of input focus. **The
IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the original "fires IMMEDIATELY (no dwell wait)" reading here was a misread
— the user's side-by-side retail comparison (retail world tooltips "lag";
ours popped instantly) sent the derivation back, and the notice's
immediate `StartTooltipAtMouse @0x004E5DFB` turned out to sit inside
`if (UIElementManager::s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`
— a real, distinct PDB field, drag-and-drop only). The ordinary hover
path STAGES the name (`SetTooltip @0x004E5D74` + the `|= 0x20` TooltipOn
bit) and the display rides the SAME `UIElementManager::CheckTooltip
@0x0045B6E0` mouse-idle dwell as UI-element tooltips: 250 ms
(`m_tooltipDelay @0x0045f75d`) since the last mouse move
(`m_lastMouseMoveTime`, stamped on EVERY move `@0x0045e736`), so a
continuously moving mouse shows nothing and the popup appears only once
the cursor rests. Found-object changes under an IDLE mouse swap the
popup the same frame (`SetTooltip`'s text-change teardown `@0x004617FF`
`ResetTooltip @0x0045C360` tail-calls `CheckTooltip`); the 10 s
duration expiry requires a fresh mouse move before re-arming
(`SwitchMouseOver(null) @0x0045b7b2`).** Gated by the
`PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips` `PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips`
— already modeled in `CharacterOptionTable`, default true), with text — already modeled in `CharacterOptionTable`, default true), with text
`ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name `ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name
@ -695,8 +712,9 @@ called out separately.
graceful close per the usual rules): hover an inventory item — a name graceful close per the usual rules): hover an inventory item — a name
tooltip should appear (with a count prefix for a stack) AND the mouse tooltip should appear (with a count prefix for a stack) AND the mouse
pointer should swap to its "found" variant; hover an NPC/creature — a name pointer should swap to its "found" variant; hover an NPC/creature — a name
tooltip should appear immediately (no perceptible delay) if "Show Tooltips" tooltip should appear after the 250 ms idle dwell (mouse must REST;
is on; hover a sign/chest/portal similarly. sweeping continuously shows nothing — corrected 2026-08-17) if "Show
Tooltips" is on; hover a sign/chest/portal similarly.
**2026-08-16/17 overnight hover/UI round, Batch A bug 1 — CLOSED same round: **2026-08-16/17 overnight hover/UI round, Batch A bug 1 — CLOSED same round:
world tooltips never cleared, stacking dozens of popups.** The world-object world tooltips never cleared, stacking dozens of popups.** The world-object

File diff suppressed because one or more lines are too long

View file

@ -287,15 +287,42 @@ public sealed class RetailTooltipPresenter : IDisposable
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ───────── // ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
// //
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound // Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00). Unlike the // @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00), CORRECTED at the
// dwell-timer UI-element path above, this trigger is EDGE-fired: retail // 2026-08-17 morning gate round (user finding 1: our world tooltips
// calls SetTooltip + StartTooltipAtMouse IMMEDIATELY when SmartBox's // popped instantly; retail's "lag"). The night round misread the notice
// found-object id CHANGES (@0x004E5D74/@0x004E5DFB) — no dwell wait — // as edge-MOUNTING ("SetTooltip + StartTooltipAtMouse IMMEDIATELY ... no
// gated per-edge by PlayerModule::ShowTooltips (@0x004E5D21, // dwell wait"): the immediate StartTooltipAtMouse @0x004E5DFB is inside
// CharacterOptionId.ShowTooltips in this port's CharacterOptionTable). // `if (UIElementManager::s_pInstance->m_dragElement != 0)` (@0x004E5D8E)
// The text is ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) // — m_dragElement is a real, distinct PDB field (acclient.h's
// (@0x004E5D3B) — the SAME call UIElement_UIItem::UpdateTooltip uses, // UIElementManager, separate from the m_pTooltipElement family), so the
// but WITHOUT that item-cell's separate stack-count "%d %s" prefix // immediate mount fires ONLY while a drag-and-drop is in progress
// ("what am I about to drop this on"). In the ordinary hover case the
// notice merely STAGES the name (UIElement::SetTooltip @0x004E5D74 +
// `|= 0x20` TooltipOn @0x004E5D79) on the wrapper, and the DISPLAY rides
// the standard per-frame dwell machinery:
//
// UIElementManager::CheckTooltip @0x0045B6E0 (per frame, hover not
// started): mouse idle since m_lastMouseMoveTime (stamped on EVERY
// move, MouseMoveHandler @0x0045e736) for >= m_tooltipDelay (0.25 s
// default @0x0045f75d; the runtime-constructed wrapper authors no
// P0x50 override) while entered over the wrapper with no capture
// (@0x0045b715) -> StartHover @0x00459250 -> the wrapper's inherited
// UIElement::MouseHover @0x00462520 (TooltipOn bit + m_tooltipEnable)
// -> StartTooltipAtMouse reads the staged m_TTText -> popup mounts.
//
// Found-object CHANGES while a popup is up tear it down via SetTooltip's
// OWN text-change teardown (@0x004617FF: owner == this && popup != null
// -> ResetTooltip @0x0045C360, which tail-calls CheckTooltip) — so with
// an IDLE mouse the replacement popup mounts the SAME frame (the dwell
// deadline long passed), while a MOVING mouse keeps pushing the deadline
// out and shows nothing until it rests. found -> 0 stages EMPTY text
// (ClearTooltip @0x004E5E30 = SetTooltip(empty) @0x004625F9): the same
// teardown fires and nothing remounts. The ShowTooltips gate
// (PlayerModule::ShowTooltips @0x004E5D21, CharacterOptionId.ShowTooltips)
// and the name resolve (@0x004E5D3B) happen at the EDGE, exactly where
// retail reads them. The text is ACCWeenieObject::GetObjectName(id,
// NAME_APPROPRIATE, 0) — the SAME call UIElement_UIItem::UpdateTooltip
// uses, but WITHOUT that item-cell's separate stack-count "%d %s" prefix
// (RecvNotice_SmartBoxObjectFound's own text-building block has no // (RecvNotice_SmartBoxObjectFound's own text-building block has no
// count logic at all — a real, decomp-confirmed asymmetry versus // count logic at all — a real, decomp-confirmed asymmetry versus
// UiItemSlot's GetTooltipDisplayName). // UiItemSlot's GetTooltipDisplayName).
@ -357,6 +384,31 @@ public sealed class RetailTooltipPresenter : IDisposable
private uint _worldHoverGuid; private uint _worldHoverGuid;
private bool _worldTooltipShowing; private bool _worldTooltipShowing;
/// <summary>The wrapper's staged <c>m_TTText</c> — written at the
/// found-object EDGE (<c>SetTooltip @0x004E5D74</c> /
/// <c>ClearTooltip @0x004E5E30</c>), displayed only when the dwell
/// machinery mounts it. Null/empty = cleared (retail's empty
/// <c>StringInfo</c>; <c>StartTooltipAtMouse @0x00460DA3</c>'s
/// <c>IsValid</c> test fails and the wrapper authors no <c>P0x49</c>
/// fallback, so nothing shows).</summary>
private string? _worldStagedText;
/// <summary>When the world popup mounted — retail
/// <c>m_tooltipStart</c>, for the <c>m_tooltipDuration</c> (10 s)
/// auto-hide (<c>CheckTooltip @0x0045b78a</c>).</summary>
private long _worldTooltipShownMs;
/// <summary>Set by the duration auto-hide: retail's expiry path calls
/// <c>SwitchMouseOver(this, nullptr) @0x0045b7b2</c>, clearing
/// <c>m_pElementLastEntered</c> — the dwell cannot re-arm until the next
/// mouse move re-enters the wrapper. Without this latch the port would
/// remount one frame after every auto-hide (staged text still present,
/// mouse still idle) in a 10 s flicker loop.</summary>
private bool _worldRearmRequiresMouseMove;
private int _worldLastSeenMouseX = int.MinValue;
private int _worldLastSeenMouseY = int.MinValue;
/// <summary> /// <summary>
/// The world-hover pick (retail's <c>SmartBox::find_object</c> via /// The world-hover pick (retail's <c>SmartBox::find_object</c> via
/// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'s fallback /// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'s fallback
@ -392,50 +444,140 @@ public sealed class RetailTooltipPresenter : IDisposable
if (WorldHoverGuidProvider is null) if (WorldHoverGuidProvider is null)
return; return;
// Post-auto-hide re-arm: retail's duration expiry ran
// SwitchMouseOver(null); the next real mouse move re-enters the
// wrapper and only THEN can the dwell restart (see the
// _worldRearmRequiresMouseMove field doc).
if (_host.MouseX != _worldLastSeenMouseX || _host.MouseY != _worldLastSeenMouseY)
{
_worldLastSeenMouseX = _host.MouseX;
_worldLastSeenMouseY = _host.MouseY;
_worldRearmRequiresMouseMove = false;
}
uint found = _host.Pick(_host.MouseX, _host.MouseY) is null uint found = _host.Pick(_host.MouseX, _host.MouseY) is null
? WorldHoverGuidProvider() ?? 0u ? WorldHoverGuidProvider() ?? 0u
: 0u; : 0u;
if (found == _worldHoverGuid) // ── The notice edge (RecvNotice_SmartBoxObjectFound @0x004E5AD0's
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires // tooltip half) — STAGES text; mounts nothing except mid-drag. ──
if (found != _worldHoverGuid)
{
_worldHoverGuid = found; _worldHoverGuid = found;
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1): string? staged = _worldStagedText;
// every found-object edge — whether to a DIFFERENT object or to if (found == 0u || WorldTooltipsEnabled?.Invoke() != true)
// none at all — tears down whatever world popup is currently up {
// FIRST, mirroring OnTooltipShow's own unconditional RemovePopup() at // @0x004E5E2A/@0x004E5E30: bit &= ~0x20 + ClearTooltip.
// its top. The pre-fix code only cleared on the found==0u edge, so an staged = null;
// A-found-B transition (walking past a run of NPCs/doors/lifestones }
// with never a frame of "nothing found" between them) called else
// TryBuildAndMountPopup again with the OLD popup still mounted as a {
// child of _host — only the _popupRoot reference got overwritten, so string? name = WorldHoverNameResolver?.Invoke(found);
// every previous popup was orphaned in the tree and never removed. // @0x004E5D48: the empty-name guard skips SetTooltip
// _popupRoot is a single field by design (retail's own single // entirely — the PREVIOUS staged text stays in place
// m_pTooltipElement slot); this restores that single-slot invariant. // (retail's own shape; nameless found objects are rare).
if (!string.IsNullOrEmpty(name))
staged = name;
}
// UIElement::SetTooltip @0x004617C0: only a text CHANGE does
// anything (@0x004617D9's operator== guard). On change, the
// popup this surface currently shows is torn down
// (@0x004617FF ResetTooltip) — the dwell block below is
// ResetTooltip's tail-called CheckTooltip, re-run this same
// frame, so an IDLE mouse remounts the replacement immediately.
//
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A
// bug 1): this teardown must fire on EVERY staging edge — an
// A-found-B transition with no intervening "nothing found"
// frame previously orphaned each old popup in the tree
// (_popupRoot single-slot invariant, retail's own single
// m_pTooltipElement).
if (!string.Equals(staged, _worldStagedText, StringComparison.Ordinal))
{
_worldStagedText = staged;
if (_worldTooltipShowing) if (_worldTooltipShowing)
RemovePopup(); RemovePopup();
if (found == 0u) // The drag-in-progress exception @0x004E5D8E: m_dragElement
return; // != 0 -> ResetTooltip + StartTooltipAtMouse IMMEDIATELY
// (@0x004E5DF0/@0x004E5DFB), no dwell — while dragging an
if (WorldTooltipsEnabled?.Invoke() != true) // item over the world you see the drop target's name at
return; // once. NOT gated on m_tooltipEnable (this path bypasses
// UIElement::MouseHover entirely).
string? text = WorldHoverNameResolver?.Invoke(found); if (!string.IsNullOrEmpty(staged) && _host.DragSource is not null)
if (string.IsNullOrEmpty(text)) {
return; if (TryBuildAndMountPopup(
SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, staged!))
// A UI-element popup cannot be showing here: UiRoot's own hover {
// (queried above) is null whenever this branch runs, so its dwell
// timer never arms and OnTooltipShow never fires concurrently.
if (TryBuildAndMountPopup(SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, text))
_worldTooltipShowing = true; _worldTooltipShowing = true;
_worldTooltipShownMs = _host.NowMs;
}
return;
}
}
} }
/// <summary>Force-hides whatever tooltip is currently showing, if any. // ── CheckTooltip @0x0045B6E0, the wrapper's share of it. ──
/// Session-reset callers use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s if (_worldTooltipShowing)
/// own role for dialogs) so a stale popup cannot survive a reconnect.</summary> {
public void HideCurrent() => RemovePopup(); // Auto-hide after m_tooltipDuration (@0x0045b78a; 10 s default
// @0x0045f767, UiRoot.TooltipDurationMs). The expiry path's
// SwitchMouseOver(null) @0x0045b7b2 means no remount until the
// mouse moves again.
if (_host.NowMs - _worldTooltipShownMs >= _host.TooltipDurationMs)
{
RemovePopup();
_worldRearmRequiresMouseMove = true;
}
return;
}
// StartTooltipAtMouse @0x00460DA3: empty staged m_TTText + no
// authored P0x49 on the runtime-constructed wrapper -> nothing.
if (string.IsNullOrEmpty(_worldStagedText))
return;
// @0x0045b715: the arm branch requires no mouse capture.
if (_host.Captured is not null)
return;
if (_worldRearmRequiresMouseMove)
return;
// @0x0045b747: mouse idle since m_lastMouseMoveTime for >= the
// global m_tooltipDelay (the wrapper authors no per-element P0x50).
if (_host.MouseIdleMs < _host.TooltipDelayMs)
return;
// UIElement::MouseHover @0x0046254C's m_tooltipEnable gate — the
// dwell-mounted path IS gated on the global enable (unlike the
// drag-immediate branch above, which bypasses MouseHover).
if (!Enabled)
return;
if (TryBuildAndMountPopup(
SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, _worldStagedText!))
{
_worldTooltipShowing = true;
_worldTooltipShownMs = _host.NowMs;
}
}
/// <summary>Force-hides whatever tooltip is currently showing, if any,
/// and forgets the staged world-hover text/guid. Session-reset callers
/// use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s own role
/// for dialogs) so neither a stale popup NOR a stale staged name (which
/// the dwell would otherwise remount over the new session's world with
/// an unmoved mouse) can survive a reconnect.</summary>
public void HideCurrent()
{
RemovePopup();
_worldHoverGuid = 0u;
_worldStagedText = null;
_worldRearmRequiresMouseMove = false;
}
/// <summary>Retail <c>UIElementManager::StartTooltip @0x0045DE90</c>'s text /// <summary>Retail <c>UIElementManager::StartTooltip @0x0045DE90</c>'s text
/// + auto-resize step. Wraps at the display width (retail's /// + auto-resize step. Wraps at the display width (retail's

View file

@ -377,6 +377,30 @@ public sealed class UiRoot : UiElement
private long _nowMs; private long _nowMs;
/// <summary>Retail <c>UIElementManager::m_lastMouseMoveTime</c>, ported
/// 1:1: stamped UNCONDITIONALLY at the top of every mouse move
/// (<c>MouseMoveHandler @0x0045E710</c>, <c>@0x0045e729</c>/<c>@0x0045e736</c>
/// — before hit-testing, capture handling, everything) and re-stamped on
/// capture release (<c>ReleaseMouseCapture @0x0045D2B0</c>,
/// <c>@0x0045d2da</c>). Distinct from <see cref="_hoverStartedMs"/>, whose
/// stamps are deliberately conditional (the <c>!_tooltipFired</c> guard in
/// <see cref="UpdateHover"/>, no stamp during captured moves) because that
/// field also carries <c>m_bHoverStarted</c> interplay. The world-hover
/// tooltip's idle-dwell gate (<see cref="Layout.RetailTooltipPresenter"/>)
/// needs retail's raw, unconditional timestamp.</summary>
private long _lastMouseMoveMs;
/// <summary>Milliseconds since the last mouse move — retail
/// <c>CheckTooltip @0x0045B6E0</c>'s dwell operand
/// (<c>@0x0045b747</c>: <c>m_lastMouseMoveTime + delay</c> vs now).</summary>
public long MouseIdleMs => _nowMs - _lastMouseMoveMs;
/// <summary>The clock <see cref="Tick"/> last ran at — retail
/// <c>Timer::local_time</c> as the UI tree sees it. Exposed for sibling
/// per-frame consumers (<see cref="Layout.RetailTooltipPresenter"/>'s
/// world-tooltip duration clock) so they share ONE frame timestamp.</summary>
public long NowMs => _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary> /// <summary>Raised when an event was not consumed by any widget.</summary>
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough; public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
@ -602,6 +626,9 @@ public sealed class UiRoot : UiElement
int dy = y - MouseY; int dy = y - MouseY;
MouseX = x; MouseX = x;
MouseY = y; MouseY = y;
// MouseMoveHandler @0x0045e729/@0x0045e736: m_lastMouseMoveTime is
// stamped before ANY routing below (resize/window-drag/capture/hover).
_lastMouseMoveMs = _nowMs;
// Window resize takes precedence over move / drag-drop / hover. // Window resize takes precedence over move / drag-drop / hover.
if (_resizeTarget is not null) if (_resizeTarget is not null)
@ -1124,6 +1151,7 @@ public sealed class UiRoot : UiElement
// tooltip is already up must leave it up, not clear-then-re-fire it // tooltip is already up must leave it up, not clear-then-re-fire it
// 250ms later without ever going through TooltipHide. // 250ms later without ever going through TooltipHide.
_hoverStartedMs = _nowMs; _hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // ReleaseMouseCapture @0x0045d2da — the same restart
NotifyCaptureLost(previous); NotifyCaptureLost(previous);
if (previous is not null) if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null); PointerCaptureChanged?.Invoke(previous, null);
@ -1299,6 +1327,7 @@ public sealed class UiRoot : UiElement
public void ResetTooltipTracking() public void ResetTooltipTracking()
{ {
_hoverStartedMs = _nowMs; _hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // same fresh idle deadline for the world-hover dwell
_tooltipFired = false; _tooltipFired = false;
} }

View file

@ -592,41 +592,103 @@ public sealed class RetailTooltipPresenterTests
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ───────── // ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound // Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @0x004E5AD0: edge-fired (no dwell wait), gated by PlayerModule:: // @0x004E5AD0's tooltip half, CORRECTED at the 2026-08-17 morning gate
// ShowTooltips, uses the fixed popup-skin pair every game-code // round (user finding 1: retail world tooltips "lag"; ours popped
// SetTooltip caller in this family shares (see RetailTooltipPresenter's // instantly). The notice STAGES the name (SetTooltip @0x004E5D74) and
// own doc note on why UIElement_SmartBoxWrapper's own P0x47/P0x48 // the DISPLAY rides UIElementManager::CheckTooltip @0x0045B6E0's
// cannot be read from the installed DAT). // mouse-idle dwell (m_lastMouseMoveTime + m_tooltipDelay, 250 ms
// default); the notice's own immediate StartTooltipAtMouse @0x004E5DFB
// fires ONLY inside the `m_dragElement != 0` branch (@0x004E5D8E —
// drag-and-drop in progress). Gated by PlayerModule::ShowTooltips at
// the edge; uses the fixed popup-skin pair every game-code SetTooltip
// caller in this family shares (see RetailTooltipPresenter's own doc
// note on why UIElement_SmartBoxWrapper's own P0x47/P0x48 cannot be
// read from the installed DAT).
private const uint WorldFoundGuid = 0x80000123u; private const uint WorldFoundGuid = 0x80000123u;
[Fact] /// <summary>A hit-testable drag SOURCE — presses on it become drag-drop
public void WorldHover_ShowsImmediately_NoDwellWait() /// candidates and a captured move past the threshold starts the drag
/// (for the <c>m_dragElement != 0</c> immediate-mount branch).</summary>
private sealed class DragSourceTarget : UiElement
{
public override bool IsDragSource => true;
public override object? GetDragPayload() => "payload";
}
private static (UiRoot Root, RetailTooltipPresenter Presenter, List<(uint, uint)> Requests)
CreateWorldHarness(Func<uint?> guidProvider, Func<uint, string?>? nameResolver = null,
Func<bool>? enabled = null)
{ {
var (root, presenter, requests) = CreateHarness(); var (root, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; presenter.WorldHoverGuidProvider = guidProvider;
presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null; presenter.WorldHoverNameResolver = nameResolver ?? (_ => "A Drudge");
presenter.WorldTooltipsEnabled = () => true; presenter.WorldTooltipsEnabled = enabled ?? (() => true);
return (root, presenter, requests);
}
[Fact]
public void WorldHover_StagesOnTheFoundEdge_MountsOnlyAfterTheIdleDwell()
{
// THE morning-gate finding-1 pin: a found-object change stages the
// name but mounts NOTHING until the mouse has been idle for the
// dwell delay (CheckTooltip @0x0045b747's m_lastMouseMoveTime +
// m_tooltipDelay test) — the night round's "edge-fired, no dwell"
// reading mounted immediately, which retail only does mid-drag.
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
// A single Tick — no root.Tick dwell timer involved at all, unlike root.Tick(0.016, 0);
// every UI-element case above. presenter.Tick(); // the found edge fires here — staged, not shown
presenter.Tick(); Assert.Empty(requests);
Assert.Equal(childrenBefore, root.Children.Count);
Assert.Equal(childrenBefore + 1, root.Children.Count); root.Tick(0.016, root.TooltipDelayMs - 1);
presenter.Tick();
Assert.Empty(requests); // one ms short of the idle deadline
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
Assert.Single(requests, r => r == (0x21000041u, 0x10000395u)); Assert.Single(requests, r => r == (0x21000041u, 0x10000395u));
Assert.Equal(childrenBefore + 1, root.Children.Count);
}
[Fact]
public void WorldHover_MouseMovingContinuously_NeverMountsUntilItRests()
{
// The user-visible half of finding 1: sweeping the cursor across
// NPCs shows NO tooltips in retail — every move restamps
// m_lastMouseMoveTime (MouseMoveHandler @0x0045e736) so the dwell
// deadline never arrives; the popup appears only once the mouse
// RESTS for the delay.
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
for (long t = 0; t <= 2000; t += 100) // 100 ms between moves < 250 ms dwell
{
root.Tick(0.016, t);
root.OnMouseMove(100 + (int)(t / 10), 100);
presenter.Tick();
}
Assert.Empty(requests);
// Rest: no further moves; the dwell elapses from the LAST move.
root.Tick(0.016, 2000 + root.TooltipDelayMs);
presenter.Tick();
Assert.Single(requests);
} }
[Fact] [Fact]
public void WorldHover_HidesWhenTheFoundGuidClears() public void WorldHover_HidesWhenTheFoundGuidClears()
{ {
var (root, presenter, _) = CreateHarness(); // found -> 0 stages EMPTY text (ClearTooltip @0x004E5E30 =
// SetTooltip(empty)) whose text-change teardown (@0x004617FF)
// removes the showing popup IMMEDIATELY — the teardown edge is not
// dwell-delayed, only the mount is.
uint? found = WorldFoundGuid; uint? found = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => found; var (root, presenter, _) = CreateWorldHarness(() => found);
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick(); presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); Assert.Equal(childrenBefore + 1, root.Children.Count);
@ -639,15 +701,15 @@ public sealed class RetailTooltipPresenterTests
[Fact] [Fact]
public void WorldHover_ShowTooltipsOff_ShowsNothing() public void WorldHover_ShowTooltipsOff_ShowsNothing()
{ {
// PlayerModule::ShowTooltips @0x004E5D21 gates the whole block — // PlayerModule::ShowTooltips @0x004E5D21 gates the whole staging
// UpdateCursorState (the found-cursor swap) is NOT gated by it, but // block — UpdateCursorState (the found-cursor swap) is NOT gated by
// that is a separate mechanism this presenter does not own. // it, but that is a separate mechanism this presenter does not own.
var (root, presenter, requests) = CreateHarness(); var (root, presenter, requests) = CreateWorldHarness(
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; () => WorldFoundGuid, enabled: () => false);
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => false;
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
presenter.Tick(); presenter.Tick();
Assert.Empty(requests); Assert.Empty(requests);
@ -657,11 +719,13 @@ public sealed class RetailTooltipPresenterTests
[Fact] [Fact]
public void WorldHover_NoNameResolved_ShowsNothing() public void WorldHover_NoNameResolved_ShowsNothing()
{ {
var (_, presenter, requests) = CreateHarness(); // @0x004E5D48: an empty resolved name skips SetTooltip entirely —
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; // with nothing previously staged, nothing ever mounts.
presenter.WorldHoverNameResolver = _ => null; var (root, presenter, requests) = CreateWorldHarness(
presenter.WorldTooltipsEnabled = () => true; () => WorldFoundGuid, nameResolver: _ => null);
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
presenter.Tick(); presenter.Tick();
Assert.Empty(requests); Assert.Empty(requests);
@ -675,47 +739,43 @@ public sealed class RetailTooltipPresenterTests
// either way the found-object pipeline here must not also fire for // either way the found-object pipeline here must not also fire for
// whatever the mouse is currently over. This port narrows that to // whatever the mouse is currently over. This port narrows that to
// "no UI element hovered at all" (see the class's own doc note). // "no UI element hovered at all" (see the class's own doc note).
var (root, presenter, requests) = CreateHarness(); var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 }; var uiElement = new HoverTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
root.AddChild(uiElement); root.AddChild(uiElement);
root.OnMouseMove(110, 110); root.OnMouseMove(110, 110);
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
presenter.Tick(); presenter.Tick();
Assert.Empty(requests); Assert.Empty(requests);
} }
[Fact] [Fact]
public void WorldHover_FoundObjectChangesDirectly_ReplacesThePopupWithoutStacking() public void WorldHover_IdleFoundSwap_ReplacesThePopupTheSameFrame_WithoutStacking()
{ {
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1): // Two mechanisms in one scenario. (1) Timing: with the mouse IDLE
// the regression that filled the user's screen with dozens of // and a popup up, a found A -> B change swaps the popup the SAME
// stacked tooltips. Walking past a run of NPCs/doors/lifestones never // frame — SetTooltip's text-change teardown (@0x004617FF
// produces a frame where the found guid is 0 — it goes straight from // ResetTooltip) tail-calls CheckTooltip, whose dwell deadline
// A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still // passed long ago, so the replacement mounts with no new wait.
// only ever have ONE popup mounted: found A, then found B (no // (2) The single-slot invariant (#409 follow-on, 2026-08-16
// intervening "nothing found" tick) must swap the popup, not add a // overnight round Batch A bug 1): walking past a run of NPCs/
// second one on top of the first. // doors/lifestones never produces a "nothing found" frame — A->B->C
// must swap ONE mounted popup, never orphan-stack the old ones.
const uint otherGuid = 0x80000456u; const uint otherGuid = 0x80000456u;
var (root, presenter, requests) = CreateHarness();
uint current = WorldFoundGuid; uint current = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => current; var (root, presenter, requests) = CreateWorldHarness(
presenter.WorldHoverNameResolver = guid => () => current,
guid == WorldFoundGuid ? "A Drudge" : "A Door"; nameResolver: guid => guid == WorldFoundGuid ? "A Drudge" : "A Door");
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick(); presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); Assert.Equal(childrenBefore + 1, root.Children.Count);
current = otherGuid; current = otherGuid;
presenter.Tick(); presenter.Tick(); // same frame: teardown + idle remount
// Exactly one popup, not two stacked.
Assert.Equal(childrenBefore + 1, root.Children.Count); Assert.Equal(childrenBefore + 1, root.Children.Count);
Assert.Equal(2, requests.Count); Assert.Equal(2, requests.Count);
@ -731,6 +791,89 @@ public sealed class RetailTooltipPresenterTests
Assert.Equal(childrenBefore + 1, root.Children.Count); Assert.Equal(childrenBefore + 1, root.Children.Count);
} }
[Fact]
public void WorldHover_AutoHidesAfterTheDuration_AndRemountsOnlyAfterAMouseMovePlusDwell()
{
// CheckTooltip's duration expiry (@0x0045b78a, m_tooltipDuration =
// 10 s @0x0045f767) tears the popup down AND runs
// SwitchMouseOver(null) (@0x0045b7b2) — m_pElementLastEntered goes
// null, so the dwell CANNOT re-arm until the next real mouse move
// re-enters the wrapper. Without that latch the port would remount
// one frame later (text still staged, mouse still idle) in a 10 s
// flicker loop.
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
long expiry = root.TooltipDelayMs + root.TooltipDurationMs;
root.Tick(0.016, expiry);
presenter.Tick();
Assert.Equal(childrenBefore, root.Children.Count); // auto-hidden
root.Tick(0.016, expiry + 500);
presenter.Tick();
Assert.Equal(childrenBefore, root.Children.Count); // idle but latched — no flicker remount
Assert.Single(requests);
long moveAt = expiry + 600;
root.Tick(0.016, moveAt);
root.OnMouseMove(5, 5); // re-enter; dwell restarts from this move
presenter.Tick();
Assert.Single(requests); // dwell not yet elapsed
root.Tick(0.016, moveAt + root.TooltipDelayMs);
presenter.Tick();
Assert.Equal(2, requests.Count);
Assert.Equal(childrenBefore + 1, root.Children.Count);
}
[Fact]
public void WorldHover_DragInProgress_MountsImmediatelyOnTheFoundEdge_NoDwell()
{
// The ONE immediate path in RecvNotice_SmartBoxObjectFound:
// @0x004E5D8E gates ResetTooltip + StartTooltipAtMouse
// (@0x004E5DF0/@0x004E5DFB) on UIElementManager's m_dragElement —
// while dragging an item over the world, the drop target's name
// shows at once, dwell or no dwell.
uint? found = null;
var (root, presenter, requests) = CreateWorldHarness(() => found);
var source = new DragSourceTarget { Left = 100, Top = 100, Width = 40, Height = 20 };
root.AddChild(source);
int childrenBefore = root.Children.Count;
root.Tick(0.016, 0);
root.OnMouseDown(UiMouseButton.Left, 110, 110);
root.OnMouseMove(130, 130); // beyond the 3px threshold -> BeginDrag
Assert.NotNull(root.DragSource);
root.OnMouseMove(300, 300); // over the world, mid-drag, mouse JUST moved
found = WorldFoundGuid;
presenter.Tick(); // the found edge, zero idle time
Assert.Single(requests);
Assert.Equal(childrenBefore + 1, root.Children.Count);
}
[Fact]
public void WorldHover_GlobalEnableOff_SuppressesTheDwellMount()
{
// The dwell-mounted path goes through UIElement::MouseHover, whose
// m_tooltipEnable gate (@0x0046254C) this presenter models as
// Enabled — unlike the drag-immediate branch, which calls
// StartTooltipAtMouse directly and bypasses MouseHover entirely.
var (root, presenter, requests) = CreateWorldHarness(() => WorldFoundGuid);
presenter.Enabled = false;
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
presenter.Tick();
Assert.Empty(requests);
}
[Fact] [Fact]
public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks() public void WorldHover_ThenUiDwellTooltip_ReplacesRatherThanStacks()
{ {
@ -738,19 +881,18 @@ public sealed class RetailTooltipPresenterTests
// showing, then the mouse settles on a real UI element (dwell path) // showing, then the mouse settles on a real UI element (dwell path)
// — OnTooltipShow's own unconditional RemovePopup() must clear the // — OnTooltipShow's own unconditional RemovePopup() must clear the
// world popup, leaving exactly one popup (the UI one), not two. // world popup, leaving exactly one popup (the UI one), not two.
var (root, presenter, _) = CreateHarness(); var (root, presenter, _) = CreateWorldHarness(() => WorldFoundGuid);
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick(); presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
var target = AddFullyAuthoredTarget(root); var target = AddFullyAuthoredTarget(root);
long moveAt = root.TooltipDelayMs + 10;
root.Tick(0.016, moveAt);
root.OnMouseMove(110, 110); root.OnMouseMove(110, 110);
root.Tick(0.016, 0); root.Tick(0.016, moveAt + root.TooltipDelayMs);
root.Tick(0.016, root.TooltipDelayMs);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target)); UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.NotNull(popup); Assert.NotNull(popup);
@ -786,6 +928,10 @@ public sealed class RetailTooltipPresenterTests
// _worldTooltipShowing, which is FALSE here (the currently-mounted // _worldTooltipShowing, which is FALSE here (the currently-mounted
// popup is UI-owned, not world-owned) — pre-fix, this let the world // popup is UI-owned, not world-owned) — pre-fix, this let the world
// path mount a SECOND popup on top without ever clearing the first. // path mount a SECOND popup on top without ever clearing the first.
// Post-finding-1: the mouse has been idle since the UI dwell fired,
// so the world dwell deadline is ALSO already met and the world
// popup mounts on this same Tick (through TryBuildAndMountPopup's
// unconditional clear).
var (root, presenter, _) = CreateHarness(); var (root, presenter, _) = CreateHarness();
var target = AddFullyAuthoredTarget(root); var target = AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count; int childrenBefore = root.Children.Count;
@ -817,13 +963,15 @@ public sealed class RetailTooltipPresenterTests
// RecvNotice_SmartBoxObjectFound only re-runs when SmartBox:: // RecvNotice_SmartBoxObjectFound only re-runs when SmartBox::
// set_found_object's target actually changes — a per-frame poll of // set_found_object's target actually changes — a per-frame poll of
// the SAME found id must not re-read ShowTooltips or re-resolve the // the SAME found id must not re-read ShowTooltips or re-resolve the
// name every tick. // name every tick, and the dwell mount must not rebuild the popup
var (_, presenter, requests) = CreateHarness(); // on later ticks either.
int gateReads = 0, nameReads = 0; int gateReads = 0, nameReads = 0;
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; var (root, presenter, requests) = CreateWorldHarness(
presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; }; () => WorldFoundGuid,
presenter.WorldTooltipsEnabled = () => { gateReads++; return true; }; nameResolver: _ => { nameReads++; return "A Drudge"; },
enabled: () => { gateReads++; return true; });
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick(); presenter.Tick();
presenter.Tick(); presenter.Tick();
presenter.Tick(); presenter.Tick();
@ -844,11 +992,10 @@ public sealed class RetailTooltipPresenterTests
// resolver is free to return whatever plain text it wants and the // resolver is free to return whatever plain text it wants and the
// presenter applies it verbatim (no separate count formatting is // presenter applies it verbatim (no separate count formatting is
// ever added by this class). // ever added by this class).
var (root, presenter, _) = CreateHarness(); var (root, presenter, _) = CreateWorldHarness(
presenter.WorldHoverGuidProvider = () => WorldFoundGuid; () => WorldFoundGuid, nameResolver: _ => "Iron Bars");
presenter.WorldHoverNameResolver = _ => "Iron Bars";
presenter.WorldTooltipsEnabled = () => true;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick(); presenter.Tick();
UiElement popup = Assert.Single(root.Children); UiElement popup = Assert.Single(root.Children);