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:
parent
e316e190cb
commit
9d9280a069
5 changed files with 444 additions and 108 deletions
|
|
@ -287,15 +287,42 @@ public sealed class RetailTooltipPresenter : IDisposable
|
|||
// ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ─────────
|
||||
//
|
||||
// Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
||||
// @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00). Unlike the
|
||||
// dwell-timer UI-element path above, this trigger is EDGE-fired: retail
|
||||
// calls SetTooltip + StartTooltipAtMouse IMMEDIATELY when SmartBox's
|
||||
// found-object id CHANGES (@0x004E5D74/@0x004E5DFB) — no dwell wait —
|
||||
// gated per-edge by PlayerModule::ShowTooltips (@0x004E5D21,
|
||||
// CharacterOptionId.ShowTooltips in this port's CharacterOptionTable).
|
||||
// The text is ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)
|
||||
// (@0x004E5D3B) — the SAME call UIElement_UIItem::UpdateTooltip uses,
|
||||
// but WITHOUT that item-cell's separate stack-count "%d %s" prefix
|
||||
// @0x004E5AD0's tooltip half (@0x004E5D13-@0x004E5E00), CORRECTED at the
|
||||
// 2026-08-17 morning gate round (user finding 1: our world tooltips
|
||||
// popped instantly; retail's "lag"). The night round misread the notice
|
||||
// as edge-MOUNTING ("SetTooltip + StartTooltipAtMouse IMMEDIATELY ... no
|
||||
// dwell wait"): the immediate StartTooltipAtMouse @0x004E5DFB is inside
|
||||
// `if (UIElementManager::s_pInstance->m_dragElement != 0)` (@0x004E5D8E)
|
||||
// — m_dragElement is a real, distinct PDB field (acclient.h's
|
||||
// UIElementManager, separate from the m_pTooltipElement family), so the
|
||||
// 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
|
||||
// count logic at all — a real, decomp-confirmed asymmetry versus
|
||||
// UiItemSlot's GetTooltipDisplayName).
|
||||
|
|
@ -357,6 +384,31 @@ public sealed class RetailTooltipPresenter : IDisposable
|
|||
private uint _worldHoverGuid;
|
||||
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>
|
||||
/// The world-hover pick (retail's <c>SmartBox::find_object</c> via
|
||||
/// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'s fallback
|
||||
|
|
@ -392,50 +444,140 @@ public sealed class RetailTooltipPresenter : IDisposable
|
|||
if (WorldHoverGuidProvider is null)
|
||||
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
|
||||
? WorldHoverGuidProvider() ?? 0u
|
||||
: 0u;
|
||||
|
||||
if (found == _worldHoverGuid)
|
||||
return; // no change -> RecvNotice_SmartBoxObjectFound never re-fires
|
||||
_worldHoverGuid = found;
|
||||
// ── The notice edge (RecvNotice_SmartBoxObjectFound @0x004E5AD0's
|
||||
// tooltip half) — STAGES text; mounts nothing except mid-drag. ──
|
||||
if (found != _worldHoverGuid)
|
||||
{
|
||||
_worldHoverGuid = found;
|
||||
|
||||
// #409 follow-on (2026-08-16 overnight hover/UI round, Batch A bug 1):
|
||||
// every found-object edge — whether to a DIFFERENT object or to
|
||||
// none at all — tears down whatever world popup is currently up
|
||||
// FIRST, mirroring OnTooltipShow's own unconditional RemovePopup() at
|
||||
// its top. The pre-fix code only cleared on the found==0u edge, so an
|
||||
// A-found-B transition (walking past a run of NPCs/doors/lifestones
|
||||
// with never a frame of "nothing found" between them) called
|
||||
// TryBuildAndMountPopup again with the OLD popup still mounted as a
|
||||
// child of _host — only the _popupRoot reference got overwritten, so
|
||||
// every previous popup was orphaned in the tree and never removed.
|
||||
// _popupRoot is a single field by design (retail's own single
|
||||
// m_pTooltipElement slot); this restores that single-slot invariant.
|
||||
string? staged = _worldStagedText;
|
||||
if (found == 0u || WorldTooltipsEnabled?.Invoke() != true)
|
||||
{
|
||||
// @0x004E5E2A/@0x004E5E30: bit &= ~0x20 + ClearTooltip.
|
||||
staged = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
string? name = WorldHoverNameResolver?.Invoke(found);
|
||||
// @0x004E5D48: the empty-name guard skips SetTooltip
|
||||
// entirely — the PREVIOUS staged text stays in place
|
||||
// (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)
|
||||
RemovePopup();
|
||||
|
||||
// The drag-in-progress exception @0x004E5D8E: m_dragElement
|
||||
// != 0 -> ResetTooltip + StartTooltipAtMouse IMMEDIATELY
|
||||
// (@0x004E5DF0/@0x004E5DFB), no dwell — while dragging an
|
||||
// item over the world you see the drop target's name at
|
||||
// once. NOT gated on m_tooltipEnable (this path bypasses
|
||||
// UIElement::MouseHover entirely).
|
||||
if (!string.IsNullOrEmpty(staged) && _host.DragSource is not null)
|
||||
{
|
||||
if (TryBuildAndMountPopup(
|
||||
SharedPopupSkinRootElementId, SharedPopupSkinLayoutDid, staged!))
|
||||
{
|
||||
_worldTooltipShowing = true;
|
||||
_worldTooltipShownMs = _host.NowMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CheckTooltip @0x0045B6E0, the wrapper's share of it. ──
|
||||
if (_worldTooltipShowing)
|
||||
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;
|
||||
}
|
||||
|
||||
if (found == 0u)
|
||||
// StartTooltipAtMouse @0x00460DA3: empty staged m_TTText + no
|
||||
// authored P0x49 on the runtime-constructed wrapper -> nothing.
|
||||
if (string.IsNullOrEmpty(_worldStagedText))
|
||||
return;
|
||||
|
||||
if (WorldTooltipsEnabled?.Invoke() != true)
|
||||
// @0x0045b715: the arm branch requires no mouse capture.
|
||||
if (_host.Captured is not null)
|
||||
return;
|
||||
|
||||
string? text = WorldHoverNameResolver?.Invoke(found);
|
||||
if (string.IsNullOrEmpty(text))
|
||||
if (_worldRearmRequiresMouseMove)
|
||||
return;
|
||||
|
||||
// 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))
|
||||
// @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.
|
||||
/// Session-reset callers use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s
|
||||
/// own role for dialogs) so a stale popup cannot survive a reconnect.</summary>
|
||||
public void HideCurrent() => RemovePopup();
|
||||
/// <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
|
||||
/// + auto-resize step. Wraps at the display width (retail's
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue