using System; using System.Linq; using AcDream.App.UI; namespace AcDream.App.UI.Layout; /// /// #409 — client-wide retail hover-tooltip system. Consumes 's /// existing hover-dwell timer (/ /// ) and, for any widget that authors the five /// tooltip properties, instantiates retail's popup LayoutDesc, auto-sizes and /// positions it, and mounts/unmounts it as a topmost sibling of the retained tree. /// /// /// Retail mechanism (decomp anchors — see also each ElementInfo.Tooltip* /// field's own doc comment for the individual property citations): /// /// UIElementManager::CheckTooltip @0x0045B6E0 — the /// per-frame dwell/auto-hide timer, ported into . /// UIElement::MouseHover @0x00462520 — the per-element /// gate (P0x4B TooltipOn bit + the global m_tooltipEnable /// preference); ported as this class's + /// check. /// UIElement::StartTooltipAtMouse @0x00460D70 — text /// resolution (P0x49) and dispatch to the manager. /// UIElementManager::StartTooltip @0x0045DE90 — /// instantiates the popup (P0x47 root element id within P0x48's /// LayoutDesc), resolves the text child (P0x4A, read off the POPUP's own /// root), sets its text, and auto-resizes the root by the measured-vs-authored /// text delta. /// UIElementManager::StartTooltip @0x00459700 — /// positions the popup's top-left at the mouse cursor OFFSET by 32px on /// BOTH axes (@0x00459739/@0x00459747), clamped to the /// display. /// /// /// /// /// Live-DAT-probed structure (#409 investigation): layout 0x21000041's /// root (id 0, 800x600, a pure catalog container) holds four 30x30 popup skins /// (0x10000487/0x10000395/0x10000397/0x10000398), /// each a four-piece bevel frame around one Type-12 text child /// 0x10000396 — every one of those four roots' own P0x4A resolves /// to exactly 0x10000396, confirming the decomp reading. 430 installed /// elements author at least one of the five properties (243 with literal /// P0x49 text this port shows; the other 187 have no literal text — /// see register row TS-85, corrected at the 2026-08-16 review round: retail's /// OWN default InqProperty(0x49) reads the SAME authored bags this /// port already reads, so most of those 187 show nothing in retail either; /// the real gap is the separate m_TTText/SetTooltip family /// headed by the P0xD0 truncated-text auto-tooltip). A fifth root id /// (0x100001F0) and a second popup layout (0x21000026) also /// appear on a handful of elements; this class is fully data-driven off /// each widget's own authored properties, so neither needed special-casing. /// /// public sealed class RetailTooltipPresenter : IDisposable { private readonly UiRoot _host; /// Resolves a tooltip popup's LayoutDesc + root element (the /// widget's own / /// ) to a mounted-ready /// . Wraps LayoutImporter.Import with the /// runtime's dat lock/resolve/font context — the same shape /// RetailUiRuntime.CreateLayout already gives . private readonly Func _createLayout; private UiElement? _popupRoot; private UiElement? _owner; private bool _disposed; public RetailTooltipPresenter(UiRoot host, Func createLayout) { _host = host ?? throw new ArgumentNullException(nameof(host)); _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout)); _host.TooltipShow += OnTooltipShow; _host.TooltipHide += OnTooltipHide; } /// /// The client-side Misc.TooltipEnable preference /// (UIElementManager::Init @0x0045EE10 registers the LIVE binding /// via UserPreferences::RegisterPreference @0x0045EE92 — see /// 's /// own doc comment for the full two-mechanism citation split — /// default true — m_tooltipEnable=1 @0x0045F756). NOT part of the /// server-synced CharacterOptionTable — retail's 2013 Config tab /// doesn't expose a row for it either (research confirms it's UserPreferences- /// only, absent from the visible tab), so this stays a plain client-local /// setting on the presenter rather than routing through /// RuntimeCharacterOptionsState. Gates ONLY the popup presentation — /// matching retail's own gate point at UIElement::MouseHover — NOT /// 's dwell timer, which keeps running either way exactly /// as retail's CheckTooltip does. /// public bool Enabled { get; set; } = true; /// /// Retail UIElement::StartTooltipAtMouse @0x00460D70's text source, in /// retail's own order: the RUNTIME m_TTText first /// (@0x00460DA3 tests StringInfo::IsValid(&m_TTText) and takes /// it verbatim at @0x00460DAA), and only when that is empty does it fall /// back to the AUTHORED P0x49 property (@0x00460DDF /// InqProperty(0x49)). /// /// /// UiElement.GetTooltipText() is this port's m_TTText: it is what /// every acdream analog of retail's ~15 game-code UIElement::SetTooltip /// call sites already writes — the Options panel's per-row /// ID_PlayerOption_*_Help strings /// (UIOption_CheckboxBitfield64::CreateChildren @0x00485E65's /// siTooltip array; acdream CharacterOptionsPageController, /// ChatOptionsPageController, ConfigOptionsPageController, /// UiCheckboxBitfield64), the Configure-Keyboard key captions, and the /// social pages' checkbox help. Reading only /// (as this class did before) is why NONE of those showed live: retail's /// in-world panels author the popup LOCATOR (P0x47/P0x48) and the /// P0x4B on-bit but deliberately author NO P0x49 text, because the /// text arrives at runtime. Live-DAT measured: the Options toggle row /// (0x2100002B/0x10000218) authors /// P0x47=0x10000397 P0x48=0x21000041 P0x4B=true P0x49=<empty>. /// /// private static string? ResolveTooltipText(UiElement widget, out bool fromRuntime) { string? runtime = widget.GetTooltipText(); if (!string.IsNullOrEmpty(runtime)) { fromRuntime = true; return runtime; } fromRuntime = false; return widget.AuthoredTooltipText; } private void OnTooltipShow(UiElement widget) { RemovePopup(); if (!Enabled) return; string? tooltipText = ResolveTooltipText(widget, out bool fromRuntime); if (string.IsNullOrEmpty(tooltipText)) return; // The P0x4B TooltipOn bit (UIElement::MouseHover @0x0046254C reads // __bitfield164 bit 5). Retail's game-code SetTooltip sites do not rely on // the authored bit — each one SETS it in the same breath as the text: // UIElement_UIItem::UpdateTooltip @0x004E1D5E, gmPaperDollUI:: // UpdateItemSlotTooltip @0x004A52F4, gmSpellcastingUI::UpdateEndowmentIcon // @0x004C63AC, SpellCastSubMenu::UpdateFromPlayerModule @0x004C67ED, // gmSpellcastingUI::UpdateCastButtonTooltip @0x004C7000, SpellCastSubMenu:: // AddFavorite @0x004C7218, gmRadarUI::DrawObjects @0x004D9617, and // UIElement_Text::RecalculateTruncation @0x00467076 — all `|= 0x20`, each // paired with a `&= ~0x20` on the clearing path. So a widget carrying // runtime tooltip text is tooltip-on by construction; only the AUTHORED- // text path consults the authored bit. if (!fromRuntime && !widget.AuthoredTooltipEnabled) return; if (widget.AuthoredTooltipRootElementId == 0u) return; // StartTooltipAtMouse @0x00460E6B reads P0x48 and, when it is absent // (@0x00460E7E), substitutes the element's OWN LayoutDesc DID // (this->m_layout->m_DID) before dispatching to StartTooltip. Only when // BOTH are absent (@0x00460E91) does it give up. uint layoutDid = widget.AuthoredTooltipLayoutDid != 0u ? widget.AuthoredTooltipLayoutDid : widget.SourceLayoutDid; if (layoutDid == 0u) return; if (TryBuildAndMountPopup(widget.AuthoredTooltipRootElementId, layoutDid, tooltipText!)) _owner = widget; } /// /// Shared popup-build/mount body for BOTH tooltip triggers this class /// owns: the UI-element hover-dwell path above (, /// per-widget authored P0x47/P0x48) and the world-object /// hover path below (, the fixed /// popup-skin pair every game-code SetTooltip caller in this /// 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 { layout = _createLayout(layoutDid, rootElementId); } catch (Exception error) { Console.WriteLine( $"[UI] #409 tooltip popup layout=0x{layoutDid:X8} " + $"root=0x{rootElementId:X8} failed to build: {error.Message}"); return false; } if (layout is null) return false; UiElement root = layout.Root; UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u ? layout.FindElement(root.AuthoredTooltipTextChildElementId) : null; // F5: retail requires GetChildRecursive to resolve AND DynamicCast // to UIElement_Text (type 0xc) before it ever calls the positioning/ // show half of StartTooltip (UIElementManager::StartTooltip // @0x0045DE90, @0x0045df59/@0x0045df65 — @0x0045df6f gates the rest // of the function on a non-null cast result). Every popup skin this // port's live-DAT sweep found resolves cleanly, so this only guards // a malformed/future LayoutDesc — but mounting anyway there would // show an empty, unsized 30x30 bevel artifact instead of retail's // silent no-op. if (textChild is not UiText text) return false; // F4: null the per-frame anchor recompute on BOTH the popup root and // its text child before resizing, the same shape the sibling // RetailMessageDialogView uses for its popup/message pair // (RetailMessageDialogView.cs:49-53) — ApplyAnchor otherwise // recomputes margins against the popup's authored edge mode every // frame, which would silently fight the resize below if that mode // isn't the assumed "no stretch" default. root.LayoutPolicy = null; root.Anchors = AnchorEdges.None; text.LayoutPolicy = null; text.Anchors = AnchorEdges.None; ApplyTooltipText(root, text, tooltipText); SetClickThroughRecursive(root); PositionAtMouse(root); _host.AddChild(root); _host.BringToFront(root); _popupRoot = root; return true; } private void OnTooltipHide(UiElement widget) { if (ReferenceEquals(_owner, widget)) RemovePopup(); } private void RemovePopup() { if (_popupRoot is null) return; _host.RemoveChild(_popupRoot); _popupRoot = null; _owner = null; _worldTooltipShowing = false; } // ── World-object hover tooltip (docs/ISSUES.md #409 follow-on) ───────── // // Port of UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound // @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). // // The found-object id itself comes from UIElement_SmartBoxWrapper:: // FindObject @0x004E5430, called every frame from Global_Loop // @0x004E5620 using the CURRENT mouse position regardless of what has // input focus. FindObject special-cases m_pElementLastOver casting to // UIElement_UIItem (SmartBox::set_found_object(itemID) — item cells // own their own answer, ported as UiItemSlot.GetTooltipText, Item 1); // otherwise it runs the ordinary 3D raycast even under non-item UI // chrome. This port narrows that second branch to "no UI element // hovered at all" (see WorldHoverGuidProvider's own doc) rather than // reproducing the raycast-under-windows edge case. // // UIElement_SmartBoxWrapper is registered class 0x10000030 // (Register @0x0047A47E) but an exhaustive live-DAT sweep // (TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_ // AnywhereInstalled) found ZERO elements of that type anywhere // installed — unlike the UIItem catalog's 49 standalone template // prototypes, the 3D-viewport wrapper is evidently constructed // directly by gmGamePlayUI's own mode setup rather than from a // walkable authored ElementDesc, so its own P0x47/P0x48 cannot be // read from the DAT. This port therefore REUSES the item catalog's // confirmed uniform popup-locator pair (SharedPopupSkinRootElementId/ // SharedPopupSkinLayoutDid below) — the SAME "generic runtime-text" // skin every other game-code SetTooltip caller in this family draws // from — as the best-evidenced inference for the unrecoverable // constant. /// /// The shared popup-skin locator pair every tooltip-bearing surface /// that authors no locator of its own resolves to. Retail's shared /// UIItem cell-template catalog (ItemListCellTemplate.CatalogLayoutId, /// LayoutDesc 0x21000041) authors the SAME /// P0x47=0x10000395/P0x48=0x21000041 pair on all 49 of /// its standalone item-cell prototypes (live-DAT-probed 2026-08-16: /// inventory's 32x32 cell, the toolbar's per-slot prototypes, the /// container cell, every paperdoll/armor slot skin — /// TooltipLiveDatTests.PopupSkinRootIds/ /// UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator) — /// one of the four 30x30 popup skins this presenter mounts for every /// authored tooltip-bearing element too. /// /// /// Night-round review F10: previously duplicated as three separate /// private constants with three separate partial citations; this is the /// ONE citation. Two consumers remain — this class's own world-hover /// popup (below) and UiItemSlot's item-cell popup. /// MapPageController's town markers were REMOVED from the list at /// the 2026-08-17 morning gate round (finding 3): the hotspot template /// (0x100001F0 in 0x21000026) turned out to author its OWN /// popup locator (P0x47=0x10000398/P0x48=0x21000041 — the /// fourth skin, whose incorporated text child fonts 0x40000015 /// where the other three font 0x40000002), which the earlier /// shared-constant override was clobbering. This class is the natural /// owner since it's the mount point every consumer ultimately routes /// through (OnTooltipShow/UpdateWorldHoverTooltip both call /// TryBuildAndMountPopup with these values or a widget's own). /// public const uint SharedPopupSkinRootElementId = 0x10000395u; public const uint SharedPopupSkinLayoutDid = 0x21000041u; private uint _worldHoverGuid; private bool _worldTooltipShowing; /// The wrapper's staged m_TTText — written at the /// found-object EDGE (SetTooltip @0x004E5D74 / /// ClearTooltip @0x004E5E30), displayed only when the dwell /// machinery mounts it. Null/empty = cleared (retail's empty /// StringInfo; StartTooltipAtMouse @0x00460DA3's /// IsValid test fails and the wrapper authors no P0x49 /// fallback, so nothing shows). private string? _worldStagedText; /// When the world popup mounted — retail /// m_tooltipStart, for the m_tooltipDuration (10 s) /// auto-hide (CheckTooltip @0x0045b78a). private long _worldTooltipShownMs; /// Set by the duration auto-hide: retail's expiry path calls /// SwitchMouseOver(this, nullptr) @0x0045b7b2, clearing /// m_pElementLastEntered — 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. private bool _worldRearmRequiresMouseMove; private int _worldLastSeenMouseX = int.MinValue; private int _worldLastSeenMouseY = int.MinValue; /// /// The world-hover pick (retail's SmartBox::find_object via /// UIElement_SmartBoxWrapper::FindObject @0x004E5430's fallback /// branch) — a per-frame "what's under the cursor right now" query, /// distinct from click-driven SelectionState. Queried only when /// finds no UI element under the cursor /// (mirrors FindObject's m_pElementLastOver check, narrowed /// per this section's own doc note). Null/unset disables the whole /// world-hover path (no world tooltip, matching a client build that /// never wires it). /// public Func? WorldHoverGuidProvider { get; set; } /// /// ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) — /// resolve the found guid's display name. Returning null/empty shows no /// tooltip for that guid (matching retail's own non-empty-string guard /// at @0x004E5D48). /// public Func? WorldHoverNameResolver { get; set; } /// /// PlayerModule::ShowTooltips (CharacterOptionId.ShowTooltips) — /// read once per found-object edge, exactly where retail reads it /// (@0x004E5D21). A mid-hover option toggle takes effect at the /// NEXT found-object change, matching retail's own edge-only /// re-evaluation rather than a live per-frame re-check. /// public Func? WorldTooltipsEnabled { get; set; } private void UpdateWorldHoverTooltip() { 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; // ── The notice edge (RecvNotice_SmartBoxObjectFound @0x004E5AD0's // tooltip half) — STAGES text; mounts nothing except mid-drag. ── if (found != _worldHoverGuid) { _worldHoverGuid = found; 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) { // 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; } } /// Force-hides whatever tooltip is currently showing, if any, /// and forgets the staged world-hover text/guid. Session-reset callers /// use this (mirrors '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. public void HideCurrent() { RemovePopup(); _worldHoverGuid = 0u; _worldStagedText = null; _worldRearmRequiresMouseMove = false; } /// Retail UIElementManager::StartTooltip @0x0045DE90's text /// + auto-resize step. Wraps at the display width (retail's /// UIElement_Text::InqSizewMargins(..., UITS_MAX_WIDTH) falls back to /// RenderDevice::GetDisplayWidth() when the text element authors no /// P0x3D max-width override — unmodeled here, no probed tooltip /// element authors one), then grows the popup ROOT by exactly the delta /// between the measured wrapped size and the text child's AUTHORED size — /// the authored gap becomes the popup's padding. Retail's final branch /// (grow further if the text child has vertical scroll overflow) has no /// acdream analog for a freshly-built, unscrolled popup and is a /// structural no-op here. /// /// /// F8 additions (2026-08-16 review round): (1) InqSizewMargins /// returns a size with the text element's own margins already added /// back in (@0x004697a4/@0x004697bc: += m_margR + /// m_margL / += m_margD + m_margU) before StartTooltip /// diffs it against the AUTHORED width/height — zeroing /// here (same shape the sibling /// RetailMessageDialogView uses for its own message child, /// RetailMessageDialogView.cs:53) keeps our margin-free measured /// size exactly comparable, without needing to separately track and /// re-add an inset this port doesn't otherwise model for the tooltip /// text child. (2) the grown size is clamped through the SAME /// authored-override clamp UIElement::ResizeTo @0x00463C30 /// applies to every resize (P0x3C max-height/P0x3E /// min-height/P0x3D max-width/P0x3F min-width, max /// checked before min on each axis) before the value is ever assigned — /// this port had been assigning the grown size directly, unclamped. /// private void ApplyTooltipText(UiElement root, UiText text, string tooltipText) { float authoredTextWidth = text.Width; float authoredTextHeight = text.Height; text.Padding = 0f; Func measure = text.DatFont is { } datFont ? datFont.MeasureWidth : text.Font is { } bitmapFont ? bitmapFont.MeasureWidth : static s => s.Length * 8f; // CA5-gate correction (2026-08-24, owner retail-render oracle): // retail sizes a tooltip in TWO passes, and the second is what makes // long tooltips multi-line. StartTooltip @0x0045DE90: // 1. MEASURE — InqSizewMargins(..., UITS_MAX_WIDTH): wrap at the // authored P0x3D max width, else the display width, producing // the measured extent. // 2. Resize the ROOT by the measured-vs-authored delta through // ResizeTo, where the popup skin's authored max/min CLAMP. // 3. RecalculateGlyphList — the text RE-WRAPS at its FINAL // (possibly clamped) width. // 4. A second ResizeTo grows the root's HEIGHT (width unchanged) // when the re-wrapped glyph extent needs more than the text // child's current height. // The pre-correction port did only pass 1, so a description longer // than the clamped popup stayed one clipped line. float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f; // CA5 re-check corrections (2026-08-24, owner screenshots vs retail): // (a) The popup skins' TEXT CHILD (0x10000396) authors P0x3D=256 — // live-DAT probed on all four skins. InqSizewMargins' // UITS_MAX_WIDTH branch reads GetAttribute_Int(0x3D) on the TEXT // element BEFORE the display-width fallback, so retail wraps // tooltip text at 256px, not the screen width. (TS-85's "zero // elements author P0x3D" sweep only covered hover TARGETS, never // the popup skins' text children.) // (b) Tooltip text is LEFT-aligned: the text child authors no // justification and retail's unauthored default is Left, while // our importer's ElementInfo default is Center — the same // wrong-default class as #410's VJustify finding. Point-fixed // here (the chat transcript does the same); the client-wide // default remains #410's scope. text.Centered = false; text.RightAligned = false; // (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on // the popup skins) participate exactly as InqSizewMargins does: // GlyphList::Recalculate wraps at (width − margL − margR) and // the measured result adds the margins back // (@0x00469762/@0x004697..: `Recalculate(..., w − margL − margR)` // then `*out += margR + margL`). Without this, line one fit one // more word than retail (256 vs retail's 252 wrap) and the text // drew flush against the parchment's right border. float marginsX = text.MarginLeft + text.MarginRight; float marginsY = text.MarginTop + text.MarginBottom; float wrapBound = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth ? MathF.Max(1f, authoredMaxTextWidth) : MathF.Max(1f, _host.EffectiveCanvasSize.X); float measureWrapWidth = MathF.Max(1f, wrapBound - marginsX); var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); float measuredWidth = (measured.Count == 0 ? 0f : measured.Max(measure)) + marginsX; float measuredHeight = measured.Count * lineHeight + marginsY; float requestedWidth = root.Width + (measuredWidth - authoredTextWidth); float requestedHeight = root.Height + (measuredHeight - authoredTextHeight); // ResizeTo's own clamp order: max first, then min, independently // per axis (@0x00463c64/@0x00463c80 for height, @0x00463c9c/ // @0x00463cba for width). if (root.AuthoredResizeMaxHeight is { } maxHeight && requestedHeight > maxHeight) requestedHeight = maxHeight; if (root.AuthoredResizeMinHeight is { } minHeight && requestedHeight < minHeight) requestedHeight = minHeight; if (root.AuthoredResizeMaxWidth is { } maxWidth && requestedWidth > maxWidth) requestedWidth = maxWidth; if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth) requestedWidth = minWidth; float authoredRootWidth = root.Width; float authoredRootHeight = root.Height; root.Width = requestedWidth; root.Height = requestedHeight; // The text child follows the root's ACTUAL growth (retail's // anchored resize) — the clamp is what makes these differ from the // measured extents. float textFinalWidth = MathF.Max( 1f, authoredTextWidth + (requestedWidth - authoredRootWidth)); if (text.AuthoredResizeMaxWidth is { } textMaxWidth) textFinalWidth = MathF.Min(textFinalWidth, textMaxWidth); float textFinalHeight = authoredTextHeight + (requestedHeight - authoredRootHeight); // Pass 3: re-wrap at the final clamped width, inside the margins // (RecalculateGlyphList wraps glyphs at width − margL − margR). var wrapped = UiText.WrapWords( tooltipText, measure, MathF.Max(1f, textFinalWidth - marginsX)); text.LinesProvider = () => wrapped .Select(line => new UiText.Line(line, text.DefaultColor)) .ToArray(); text.Width = wrapped.Count == 0 ? 0f : MathF.Min(textFinalWidth, wrapped.Max(measure) + marginsX); float rewrappedHeight = wrapped.Count * lineHeight + marginsY; // Pass 4: grow the root's height by the re-wrap's overflow beyond // the text child's post-resize height (width unchanged), through // the same ResizeTo clamps. if (rewrappedHeight > textFinalHeight) { float grownHeight = root.Height + (rewrappedHeight - textFinalHeight); if (root.AuthoredResizeMaxHeight is { } maxH2 && grownHeight > maxH2) grownHeight = maxH2; if (root.AuthoredResizeMinHeight is { } minH2 && grownHeight < minH2) grownHeight = minH2; root.Height = grownHeight; } text.Height = rewrappedHeight; } /// Retail UIElementManager::StartTooltip @0x00459700: the /// popup's top-left lands at the mouse cursor OFFSET by /// on BOTH axes (@0x00459739: mouseX + 0x20; @0x00459747: /// mouseY + 0x20), clamped so it never crosses the right/bottom /// display edge (nor goes negative, mirroring retail's own /// max(0, ...) defensive clamp, @0x00459753/@0x00459769). /// Retail's own clamp ORDER (max-vs-display computed against the raw /// offset mouse position, not the already-floored one — @0x00459784- /// @0x0045979c) can go negative when the popup is bigger than the /// display; cannot express that (its min /// must be max) — immaterial for every popup this port /// builds (fixed 30x30 skins plus a bounded auto-grow), left unmatched. private const float MouseOffsetPx = 32f; private void PositionAtMouse(UiElement root) { var canvas = _host.EffectiveCanvasSize; float x = Math.Clamp(_host.MouseX + MouseOffsetPx, 0, MathF.Max(0f, canvas.X - root.Width)); float y = Math.Clamp(_host.MouseY + MouseOffsetPx, 0, MathF.Max(0f, canvas.Y - root.Height)); root.Left = x; root.Top = y; } /// A tooltip must never intercept the pointer — the very next /// hover-hit-test would otherwise find the popup itself and immediately /// dismiss it (no decomp counterpart needed: retail's tooltip is a /// separate, non-hit-tested presentation layer by construction, per the /// AP-229 register row's own finding on dialogs). /// and already default ClickThrough=true, but /// this walk makes the guarantee unconditional across whatever the popup /// LayoutDesc happens to be authored with. private static void SetClickThroughRecursive(UiElement element) { element.ClickThrough = true; foreach (UiElement child in element.Children) SetClickThroughRecursive(child); } /// Re-asserts the popup's z-order above whatever /// raised this frame — mirrors that /// factory's own per-tick BringToFront re-raise (see its own doc /// comment on the dialog/screen sibling z-order war, register AP-229) so a /// dialog opened WHILE a tooltip is already showing cannot bury it. Must /// run after ticks both /// and in the same /// frame — see the divergence register row this class's own commit files /// for the acknowledged "sibling with a later re-raise" shape. public void Tick() { if (_popupRoot is not null) _host.BringToFront(_popupRoot); UpdateWorldHoverTooltip(); } public void Dispose() { if (_disposed) return; _disposed = true; _host.TooltipShow -= OnTooltipShow; _host.TooltipHide -= OnTooltipHide; RemovePopup(); } }