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

@ -377,6 +377,30 @@ public sealed class UiRoot : UiElement
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>
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
@ -602,6 +626,9 @@ public sealed class UiRoot : UiElement
int dy = y - MouseY;
MouseX = x;
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.
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
// 250ms later without ever going through TooltipHide.
_hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // ReleaseMouseCapture @0x0045d2da — the same restart
NotifyCaptureLost(previous);
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
@ -1299,6 +1327,7 @@ public sealed class UiRoot : UiElement
public void ResetTooltipTracking()
{
_hoverStartedMs = _nowMs;
_lastMouseMoveMs = _nowMs; // same fresh idle deadline for the world-hover dwell
_tooltipFired = false;
}