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

@ -592,41 +592,103 @@ public sealed class RetailTooltipPresenterTests
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @0x004E5AD0: edge-fired (no dwell wait), gated by PlayerModule::
// ShowTooltips, 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).
// @0x004E5AD0's tooltip half, CORRECTED at the 2026-08-17 morning gate
// round (user finding 1: retail world tooltips "lag"; ours popped
// instantly). The notice STAGES the name (SetTooltip @0x004E5D74) and
// the DISPLAY rides UIElementManager::CheckTooltip @0x0045B6E0's
// 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;
[Fact]
public void WorldHover_ShowsImmediately_NoDwellWait()
/// <summary>A hit-testable drag SOURCE — presses on it become drag-drop
/// 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();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null;
presenter.WorldTooltipsEnabled = () => true;
presenter.WorldHoverGuidProvider = guidProvider;
presenter.WorldHoverNameResolver = nameResolver ?? (_ => "A Drudge");
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;
// A single Tick — no root.Tick dwell timer involved at all, unlike
// every UI-element case above.
presenter.Tick();
root.Tick(0.016, 0);
presenter.Tick(); // the found edge fires here — staged, not shown
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.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]
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;
presenter.WorldHoverGuidProvider = () => found;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
var (root, presenter, _) = CreateWorldHarness(() => found);
int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
@ -639,15 +701,15 @@ public sealed class RetailTooltipPresenterTests
[Fact]
public void WorldHover_ShowTooltipsOff_ShowsNothing()
{
// PlayerModule::ShowTooltips @0x004E5D21 gates the whole block —
// UpdateCursorState (the found-cursor swap) is NOT gated by it, but
// that is a separate mechanism this presenter does not own.
var (root, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => false;
// PlayerModule::ShowTooltips @0x004E5D21 gates the whole staging
// block — UpdateCursorState (the found-cursor swap) is NOT gated by
// it, but that is a separate mechanism this presenter does not own.
var (root, presenter, requests) = CreateWorldHarness(
() => WorldFoundGuid, enabled: () => false);
int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
presenter.Tick();
Assert.Empty(requests);
@ -657,11 +719,13 @@ public sealed class RetailTooltipPresenterTests
[Fact]
public void WorldHover_NoNameResolved_ShowsNothing()
{
var (_, presenter, requests) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => null;
presenter.WorldTooltipsEnabled = () => true;
// @0x004E5D48: an empty resolved name skips SetTooltip entirely —
// with nothing previously staged, nothing ever mounts.
var (root, presenter, requests) = CreateWorldHarness(
() => WorldFoundGuid, nameResolver: _ => null);
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
presenter.Tick();
Assert.Empty(requests);
@ -675,47 +739,43 @@ public sealed class RetailTooltipPresenterTests
// either way the found-object pipeline here must not also fire for
// whatever the mouse is currently over. This port narrows that to
// "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 };
root.AddChild(uiElement);
root.OnMouseMove(110, 110);
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
root.Tick(0.016, root.TooltipDelayMs + 50);
presenter.Tick();
Assert.Empty(requests);
}
[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):
// the regression that filled the user's screen with dozens of
// stacked tooltips. Walking past a run of NPCs/doors/lifestones never
// produces a frame where the found guid is 0 — it goes straight from
// A to B to C. RecvNotice_SmartBoxObjectFound-equivalent must still
// only ever have ONE popup mounted: found A, then found B (no
// intervening "nothing found" tick) must swap the popup, not add a
// second one on top of the first.
// Two mechanisms in one scenario. (1) Timing: with the mouse IDLE
// and a popup up, a found A -> B change swaps the popup the SAME
// frame — SetTooltip's text-change teardown (@0x004617FF
// ResetTooltip) tail-calls CheckTooltip, whose dwell deadline
// passed long ago, so the replacement mounts with no new wait.
// (2) The single-slot invariant (#409 follow-on, 2026-08-16
// overnight round Batch A bug 1): walking past a run of NPCs/
// 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;
var (root, presenter, requests) = CreateHarness();
uint current = WorldFoundGuid;
presenter.WorldHoverGuidProvider = () => current;
presenter.WorldHoverNameResolver = guid =>
guid == WorldFoundGuid ? "A Drudge" : "A Door";
presenter.WorldTooltipsEnabled = () => true;
var (root, presenter, requests) = CreateWorldHarness(
() => current,
nameResolver: guid => guid == WorldFoundGuid ? "A Drudge" : "A Door");
int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count);
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(2, requests.Count);
@ -731,6 +791,89 @@ public sealed class RetailTooltipPresenterTests
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]
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)
// — OnTooltipShow's own unconditional RemovePopup() must clear the
// world popup, leaving exactly one popup (the UI one), not two.
var (root, presenter, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "A Drudge";
presenter.WorldTooltipsEnabled = () => true;
var (root, presenter, _) = CreateWorldHarness(() => WorldFoundGuid);
int childrenBefore = root.Children.Count;
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
Assert.Equal(childrenBefore + 1, root.Children.Count); // world tooltip up
var target = AddFullyAuthoredTarget(root);
long moveAt = root.TooltipDelayMs + 10;
root.Tick(0.016, moveAt);
root.OnMouseMove(110, 110);
root.Tick(0.016, 0);
root.Tick(0.016, root.TooltipDelayMs);
root.Tick(0.016, moveAt + root.TooltipDelayMs);
UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
Assert.NotNull(popup);
@ -786,6 +928,10 @@ public sealed class RetailTooltipPresenterTests
// _worldTooltipShowing, which is FALSE here (the currently-mounted
// 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.
// 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 target = AddFullyAuthoredTarget(root);
int childrenBefore = root.Children.Count;
@ -817,13 +963,15 @@ public sealed class RetailTooltipPresenterTests
// RecvNotice_SmartBoxObjectFound only re-runs when SmartBox::
// 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
// name every tick.
var (_, presenter, requests) = CreateHarness();
// name every tick, and the dwell mount must not rebuild the popup
// on later ticks either.
int gateReads = 0, nameReads = 0;
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; };
presenter.WorldTooltipsEnabled = () => { gateReads++; return true; };
var (root, presenter, requests) = CreateWorldHarness(
() => WorldFoundGuid,
nameResolver: _ => { nameReads++; return "A Drudge"; },
enabled: () => { gateReads++; return true; });
root.Tick(0.016, root.TooltipDelayMs);
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
// presenter applies it verbatim (no separate count formatting is
// ever added by this class).
var (root, presenter, _) = CreateHarness();
presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
presenter.WorldHoverNameResolver = _ => "Iron Bars";
presenter.WorldTooltipsEnabled = () => true;
var (root, presenter, _) = CreateWorldHarness(
() => WorldFoundGuid, nameResolver: _ => "Iron Bars");
root.Tick(0.016, root.TooltipDelayMs);
presenter.Tick();
UiElement popup = Assert.Single(root.Children);