diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
index e58e6e9a..44f2d482 100644
--- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
+++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
@@ -186,9 +186,34 @@ public sealed class RetailTooltipPresenter : IDisposable
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
/// OnTooltipShow body — same F4/F5/F8 fixes, same failure
/// handling.
+ ///
+ ///
+ /// Night-round review F5: the single-popup invariant (retail's own
+ /// single m_pTooltipElement slot) is now enforced HERE,
+ /// structurally, rather than relying on every caller to have already
+ /// cleared a stale popup before reaching this method. Both existing
+ /// callers already clear on their own early-return paths too (a hover
+ /// change that resolves to no valid tooltip text must still tear down
+ /// the PREVIOUS popup, which never reaches this method at all), so
+ /// those calls stay — this is a belt-and-braces guarantee, not a
+ /// replacement for them. It closes a real hole: 's
+ /// own clear is gated on _worldTooltipShowing (only true when the
+ /// WORLD path itself mounted the current popup) and its "a UI popup
+ /// cannot be showing here" comment assumed 's hover
+ /// query is null whenever that branch runs — an assumption that does
+ /// not hold the instant a modal dialog opens over a stationary cursor:
+ /// the UI dwell popup from stays mounted
+ /// (_owner/_popupRoot set, _worldTooltipShowing
+ /// still false) while the world path could independently find an
+ /// object and call this method, mounting a second popup on top. Now it
+ /// cannot: this call clears whatever is mounted, UI-owned or
+ /// world-owned, before either ever gets a chance to layer.
+ ///
///
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
{
+ RemovePopup();
+
ImportedLayout? layout;
try
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
index 0410533c..4cdc83a0 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs
@@ -757,6 +757,58 @@ public sealed class RetailTooltipPresenterTests
// childrenBefore world-target(none) + target(1) + popup(1) == +2 total,
// never +3 (world popup replaced, not stacked).
Assert.Equal(childrenBefore + 2, root.Children.Count);
+
+ // Night-round review F6: the test previously stopped here, which
+ // only proved OnTooltipShow's OWN unconditional RemovePopup()
+ // cleared the world popup — it never actually exercised what
+ // happens on the NEXT presenter.Tick() (UpdateWorldHoverTooltip
+ // still thinks a world-hover target exists, since its own
+ // _worldHoverGuid/_worldTooltipShowing bookkeeping was never
+ // re-evaluated after the transition). The mouse is now over the UI
+ // target, so Pick(...) finds it and WorldHoverGuidProvider is
+ // ignored (found=0u) — this must leave the UI popup exactly as-is,
+ // no incorrect extra removal or re-mount.
+ presenter.Tick();
+ Assert.Equal(childrenBefore + 2, root.Children.Count);
+ Assert.Same(popup, root.Children.Single(c => !ReferenceEquals(c, target)));
+ }
+
+ [Fact]
+ public void UiDwellTooltip_ThenModalStealsHitTesting_WorldHoverReplacesRatherThanStacks()
+ {
+ // Night-round review F5's own reproduction: the UI->world hole. A
+ // UI element's dwell tooltip is showing; a modal then opens WITHOUT
+ // the mouse moving (UiRoot.Modal claims EXCLUSIVE hit-testing —
+ // HitTestTopDown @0x... "Modal gets exclusive hit-test" — so
+ // Pick(MouseX, MouseY) now returns null even though the tooltip's
+ // owner widget is still mounted, still visible, and its popup is
+ // still up). UpdateWorldHoverTooltip's own clear is gated on
+ // _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.
+ var (root, presenter, _) = CreateHarness();
+ var target = AddFullyAuthoredTarget(root);
+ int childrenBefore = root.Children.Count;
+
+ root.OnMouseMove(110, 110);
+ root.Tick(0.016, 0);
+ root.Tick(0.016, root.TooltipDelayMs);
+ Assert.Equal(childrenBefore + 1, root.Children.Count); // UI tooltip up
+
+ // Modal opens elsewhere on screen, stealing exclusive hit-testing —
+ // the mouse never moves.
+ root.Modal = new UiPanel { Left = 0, Top = 0, Width = 10, Height = 10 };
+ presenter.WorldHoverGuidProvider = () => WorldFoundGuid;
+ presenter.WorldHoverNameResolver = _ => "A Drudge";
+ presenter.WorldTooltipsEnabled = () => true;
+
+ presenter.Tick();
+
+ // Exactly one popup (the world one, having replaced the UI one) —
+ // never two stacked.
+ Assert.Equal(childrenBefore + 1, root.Children.Count);
+ UiElement popup = root.Children.Single(c => !ReferenceEquals(c, target));
+ Assert.NotNull(popup);
}
[Fact]