From e29c61a3a4738cf010eca638c1f5671b88cad42f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 23:02:10 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20inventory/shortcut/paperdoll=20?= =?UTF-8?q?item-name=20tooltips=20=E2=80=94=20UIElement=5FUIItem::UpdateTo?= =?UTF-8?q?oltip=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail UIElement_UIItem::UpdateTooltip @0x004E1CB0 caches the item's NAME_APPROPRIATE display name (stack-count-prefixed "%d %s" when StackSize > 1) as m_TTText every UIItem_Update refresh; the generic UIElementManager::CheckTooltip dwell timer is what actually shows it on hover — no special-cased trigger of its own. UiItemSlot cells are built programmatically (never through LayoutImporter.Build), so #409's original round left this deferred: the class carried neither the popup locator (P0x47/P0x48) nor a name source. A live-DAT sweep of the shared UIItem cell-template catalog (ItemListCellTemplate.CatalogLayoutId, 0x21000037) found all 47 UIItem-type (class 0x10000032) prototypes — inventory's cell, every toolbar slot, every paperdoll/armor slot skin — resolve the IDENTICAL popup locator (P0x47=0x10000395/P0x48=0x21000041) through catalog inheritance, with no literal text authored on any of them. UiItemSlot now hardcodes that pair and exposes GetTooltipText() via a new TooltipTextResolve delegate, wired at every physical-item construction site: InventoryController (main-pack cell + grid cells), ExternalContainerController, PaperdollController (closes the separate gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF gap too — same cell class, same fix), VendorUiController (shop/buying/selling lists), SecureTradeUiController, ToolbarController. Text is the new ClientObject.GetTooltipDisplayName(): GetAppropriateName() prefixed with the stack count via "{count} {name}" when StackSize > 1, matching UpdateTooltip's exact NAME_APPROPRIATE + "%d %s" sprintf. UiCatalogSlot (spell/component catalog cells, a different UiItemSlot subclass) is unaffected — it already overrides GetTooltipText() with its own Label. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/ExternalContainerController.cs | 1 + .../UI/Layout/InventoryController.cs | 12 ++++- .../UI/Layout/PaperdollController.cs | 1 + .../UI/Layout/SecureTradeUiController.cs | 1 + .../UI/Layout/ToolbarController.cs | 1 + .../UI/Layout/VendorUiController.cs | 6 +++ src/AcDream.App/UI/UiItemSlot.cs | 52 ++++++++++++++++++- src/AcDream.Core/Items/ClientObject.cs | 16 ++++++ 8 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index d7bf4460..db66e5ed 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -363,6 +363,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine SpriteResolve = owner.SpriteResolve, SlotIndex = owner.GetNumUIItems(), SourceKind = source, + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(guid, icon, dragIconTexture: dragIcon); return cell; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index f39b8e83..320a6eec 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -444,7 +444,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate) _topContainer.Flush(); - var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; + var main = new UiItemSlot + { + SpriteResolve = _topContainer.SpriteResolve, + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), + }; main.SetItem( p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u), @@ -486,7 +490,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint dragTex = item is null ? 0u : _dragIconIds?.Invoke( item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u; - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; + var cell = new UiItemSlot + { + SpriteResolve = list.SpriteResolve, + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), + }; cell.SetItem(guid, tex, dragIconTexture: dragTex); cell.SetWaitingState(waiting); cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 9d84f10b..4d925ed9 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -88,6 +88,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo list.ExamineItemRequested = ExamineItem; list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot + list.Cell.TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(); list.Cell.EmptySprite = emptySlotSprites is not null && emptySlotSprites.TryGetValue(element, out uint authoredSprite) ? authoredSprite diff --git a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs index b03fc02b..091a5574 100644 --- a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs +++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs @@ -295,6 +295,7 @@ public sealed class SecureTradeUiController : IRetainedPanelController // Your staged items carry retail's trading marker. ShowTradeOverlay = side == RuntimeTradeSide.Self, TradeOverlaySprite = TradeOverlaySpriteId, + TooltipTextResolve = g => _bindings.Objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(guid, icon); list.AddItem(cell); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 9d880d84..e0b0c010 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -145,6 +145,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont list.Cell.SlotIndex = i; list.Cell.SourceKind = ItemDragSource.ShortcutBar; list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) + list.Cell.TooltipTextResolve = g => _repo.Get(g)?.GetTooltipDisplayName(); } } diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index b1edcc8b..b1ddf49e 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -1102,6 +1102,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // comment for why this must be gated at the source, // not left to every destination handler to reject. AllowDragSource = false, + // Shop items are materialized into ClientObjectTable + // (VendorShopItemMaterializer), so the same resolver + // every other physical cell uses works here too. + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(item.ItemGuid, icon); cell.Selected = item.ItemGuid == selectedGuid; @@ -2205,6 +2209,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems(), AllowDragSource = false, + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(shopItem.ItemGuid, icon); cell.Selected = shopItem.ItemGuid == selectedGuid; @@ -2243,6 +2248,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems(), AllowDragSource = false, + TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(item.ObjectId, icon); cell.Selected = item.ObjectId == selectedGuid; diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 614ec3b6..8a06c830 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -12,13 +12,63 @@ namespace AcDream.App.UI; /// public class UiItemSlot : UiElement { - public UiItemSlot() { ClickThrough = false; } + /// + /// Retail's shared UIItem cell-template catalog (ItemListCellTemplate. + /// CatalogLayoutId, LayoutDesc 0x21000037) authors the SAME + /// tooltip popup locator on every one of its 49 standalone prototypes — + /// live-DAT-probed 2026-08-16: every top-level catalog child (inventory's + /// 32x32 cell 0x1000033A, the toolbar's per-slot prototypes + /// 0x1000043B.., the container cell 0x1000033F, and every + /// paperdoll/armor slot skin alike) resolves P0x47=0x10000395 / + /// P0x48=0x21000041 through catalog inheritance, matching one of + /// the four popup skins already + /// mounts for every other tooltip-bearing element + /// (Layout.TooltipLiveDatTests.PopupSkinRootIds). Since + /// cells are built programmatically (never through + /// LayoutImporter.Build), this port hardcodes the uniform pair here + /// rather than re-deriving it per instance — the same "exhaustive scan, + /// then hardcode" shape as RetailCursorCatalog's five window-control + /// cursor DIDs and ItemListCellTemplate.CatalogLayoutId itself. + /// + private const uint ItemTooltipRootElementId = 0x10000395u; + private const uint ItemTooltipLayoutDid = 0x21000041u; + + public UiItemSlot() + { + ClickThrough = false; + AuthoredTooltipRootElementId = ItemTooltipRootElementId; + AuthoredTooltipLayoutDid = ItemTooltipLayoutDid; + } public override bool ConsumesDatChildren => true; /// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID. public uint ItemId { get; private set; } + /// + /// Resolves to its retail tooltip text (a + /// + /// call bound by the owning controller — every construction site already + /// has a ClientObjectTable reference in scope, matching how + /// is wired). Null/empty result shows no + /// tooltip, matching retail's UIItem_Update early-out + /// (weenObj == 0 -> UIElement::ClearTooltip) for an + /// empty or not-yet-materialized cell. + /// + public Func? TooltipTextResolve { get; set; } + + /// + /// Port of UIElement_UIItem::UpdateTooltip @0x004E1CB0: called every + /// refresh (retail's own callers are heartbeat/state-change driven), but + /// computed lazily here — the same "runtime text on demand" shape already + /// established by and + /// — rather than cached at + /// time, since nothing observes a stale value between + /// item-state changes and the next hover dwell. + /// + public override string? GetTooltipText() + => ItemId != 0 ? TooltipTextResolve?.Invoke(ItemId) : null; + /// Pre-composited icon GL texture for the bound item (0 = none). public uint IconTexture { get; private set; } diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 5f4611d0..42a99890 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -347,6 +347,22 @@ public sealed class ClientObject if (string.IsNullOrEmpty(Name)) return Name; return Name[^1] == 's' ? Name + "es" : Name + "s"; } + + /// + /// Ports UIElement_UIItem::UpdateTooltip @0x004E1CB0 — the item-cell + /// hover-tooltip text every physical item/container/shortcut cell shows. + /// Retail resolves the name with NAME_APPROPRIATE (the same call + /// already ports) and, only when the stack + /// holds more than one (_stackSize_1 > 1 @0x004e1d12), prefixes the + /// count via PStringBase<unsigned short>::sprintf(&__return, + /// u"%d %s") — count first, one space, then the (already + /// singular-or-plural) name. + /// + public string GetTooltipDisplayName() + { + string name = GetAppropriateName(); + return StackSize > 1 ? $"{StackSize} {name}" : name; + } } /// From fe1bc70753415b8859cbade07e434076b411fb4b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 23:02:29 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20world-object=20hover=20tooltip?= =?UTF-8?q?=20=E2=80=94=20UIElement=5FSmartBoxWrapper::RecvNotice=5FSmartB?= =?UTF-8?q?oxObjectFound=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOT the UI-element dwell-timer path. Retail's mechanism is UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0, fed every frame by FindObject @0x004E5430/Global_Loop @0x004E5620 using the current mouse position regardless of input focus. It fires IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the PlayerModule::ShowTooltips character option (already modeled in CharacterOptionTable, default true), with text ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0) — the SAME name call as item tooltips, but WITHOUT the item-cell's separate stack-count prefix (a ground pile of arrows shows "Arrows", not "20 Arrows" — a real, decomp-confirmed asymmetry). Ported as RetailTooltipPresenter.UpdateWorldHoverTooltip, driven by the SAME world-hover pick CursorFeedbackController's own found-cursor already uses (WorldSelectionQuery.PickAtCursor, includeSelf: true — own player is included on that precedent) and the SAME ClientObjectTable-backed name resolver SocialAllegiancePageController's ResolveWorldObjectName already established as this codebase's pattern. New WorldTooltipRuntimeBindings threads it through RetailUiRuntimeBindings; wired at InteractionRetainedUiComposition alongside the existing cursorFeedback construction. Queried only when no UI element is hovered — a narrowing from retail's literal "raycast even under non-item UI chrome" (FindObject's m_pElementLastOver check), called out in the class's own doc note as a scoped interpretation rather than a byte-exact port. The exact popup skin is an inference, not a measured value: an exhaustive live-DAT sweep found UIElement_SmartBoxWrapper (class 0x10000030) has NO authored ElementDesc anywhere installed — unlike every other tooltip trigger, it is evidently constructed directly by gmGamePlayUI's own mode setup, not from a walkable LayoutDesc. This port reuses the same P0x47=0x10000395/P0x48=0x21000041 pair every other game-code SetTooltip caller in this family resolves to — the best-evidenced choice, called out in register row TS-85 rather than silently assumed exact. Live-verified against a connected ACE session (session-config launch, +Acdream): hovering a "Silver Tusker" near spawn mounted the correct popup text and simultaneously flipped the cursor to its DefaultFound variant, confirming the shared found-object pipeline drives both. Co-Authored-By: Claude Fable 5 --- .../InteractionRetainedUiComposition.cs | 11 ++ .../UI/Layout/RetailTooltipPresenter.cs | 148 +++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 22 ++- .../UI/Layout/RetailTooltipPresenterTests.cs | 144 +++++++++++++++++ .../UI/Layout/TooltipLiveDatTests.cs | 119 ++++++++++++++ 5 files changed, 436 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index ec368803..90bbc822 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -842,6 +842,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Actions.Selection, text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)), Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), + // #409 follow-on ("Item 2"): world-object hover tooltip. + // Reuses the SAME world-hover pick cursorFeedback's own + // worldTargetProvider already calls (UIElement_SmartBoxWrapper:: + // FindObject's 3D-raycast fallback — RetailWorldPicker's exact + // port) and the SAME ClientObjectTable name resolver + // ResolveWorldObjectName already uses elsewhere in this file. + WorldTooltip: new WorldTooltipRuntimeBindings( + HoverGuidAtCursor: () => late.Selection.PickAtCursor(includeSelf: true), + ResolveName: guid => d.Inventory.Objects.Get(guid)?.GetAppropriateName(), + Enabled: () => d.Character.Options.GetOptionBit( + CharacterOptionId.ShowTooltips)), Confirmations: new ConfirmationRuntimeBindings( (type, context, accepted) => late.Session.CurrentSession?.SendConfirmationResponse( diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index dd43c8fb..0ceab77f 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -173,20 +173,36 @@ public sealed class RetailTooltipPresenter : IDisposable 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. + /// + private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText) + { ImportedLayout? layout; try { - layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId); + layout = _createLayout(layoutDid, rootElementId); } catch (Exception error) { Console.WriteLine( $"[UI] #409 tooltip popup layout=0x{layoutDid:X8} " - + $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}"); - return; + + $"root=0x{rootElementId:X8} failed to build: {error.Message}"); + return false; } if (layout is null) - return; + return false; UiElement root = layout.Root; UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u @@ -202,7 +218,7 @@ public sealed class RetailTooltipPresenter : IDisposable // show an empty, unsized 30x30 bevel artifact instead of retail's // silent no-op. if (textChild is not UiText text) - return; + 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 @@ -216,7 +232,7 @@ public sealed class RetailTooltipPresenter : IDisposable text.LayoutPolicy = null; text.Anchors = AnchorEdges.None; - ApplyTooltipText(root, text, tooltipText!); + ApplyTooltipText(root, text, tooltipText); SetClickThroughRecursive(root); PositionAtMouse(root); @@ -224,7 +240,7 @@ public sealed class RetailTooltipPresenter : IDisposable _host.AddChild(root); _host.BringToFront(root); _popupRoot = root; - _owner = widget; + return true; } private void OnTooltipHide(UiElement widget) @@ -240,6 +256,122 @@ public sealed class RetailTooltipPresenter : IDisposable _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). 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 + // (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 (WorldPopupRootElementId/ + // WorldPopupLayoutDid 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. + + /// Same popup skin every UIItem prototype resolves to + /// ('s own ItemTooltipRootElementId) — + /// see this section's own doc note on why the exact value cannot be + /// read off an authored UIElement_SmartBoxWrapper ElementDesc. + private const uint WorldPopupRootElementId = 0x10000395u; + private const uint WorldPopupLayoutDid = 0x21000041u; + + private uint _worldHoverGuid; + private bool _worldTooltipShowing; + + /// + /// 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; + + 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; + + if (found == 0u) + { + if (_worldTooltipShowing) + RemovePopup(); + return; + } + + if (WorldTooltipsEnabled?.Invoke() != true) + return; + + string? text = WorldHoverNameResolver?.Invoke(found); + if (string.IsNullOrEmpty(text)) + 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(WorldPopupRootElementId, WorldPopupLayoutDid, text)) + _worldTooltipShowing = true; } /// Force-hides whatever tooltip is currently showing, if any. @@ -371,6 +503,8 @@ public sealed class RetailTooltipPresenter : IDisposable { if (_popupRoot is not null) _host.BringToFront(_popupRoot); + + UpdateWorldHoverTooltip(); } public void Dispose() diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 15961ae3..64304dfd 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -333,6 +333,17 @@ public sealed record RetailUiCursorBindings( CursorFeedbackController Feedback, RetailCursorManager Manager); +/// +/// #409 follow-on (docs/ISSUES.md "Item 2"): world-object hover tooltip +/// bindings for 's world-hover half — see +/// that class's own doc note on UIElement_SmartBoxWrapper:: +/// RecvNotice_SmartBoxObjectFound @0x004E5AD0. +/// +public sealed record WorldTooltipRuntimeBindings( + Func HoverGuidAtCursor, + Func ResolveName, + Func Enabled); + public sealed record ConfirmationRuntimeBindings( Action SendResponse); @@ -435,6 +446,7 @@ public sealed record RetailUiRuntimeBindings( ExternalContainerRuntimeBindings ExternalContainer, VendorRuntimeBindings Vendor, RetailUiCursorBindings Cursor, + WorldTooltipRuntimeBindings WorldTooltip, ConfirmationRuntimeBindings Confirmations, AppraisalRuntimeBindings Appraisal, OptionsRuntimeBindings Options, @@ -3366,7 +3378,15 @@ public sealed class RetailUiRuntime : IDisposable } } - TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout); + TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout) + { + // #409 follow-on ("Item 2"): the world-object hover half — + // see RetailTooltipPresenter's own doc note on + // UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound. + WorldHoverGuidProvider = () => _bindings.WorldTooltip.HoverGuidAtCursor(), + WorldHoverNameResolver = _bindings.WorldTooltip.ResolveName, + WorldTooltipsEnabled = () => _bindings.WorldTooltip.Enabled(), + }; if (_bindings.Chat.Store is { } store) { diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 18b69384..4095519f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -589,4 +589,148 @@ public sealed class RetailTooltipPresenterTests if (FindById(child, datElementId) is { } found) return found; return null; } + + // ── 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). + + private const uint WorldFoundGuid = 0x80000123u; + + [Fact] + public void WorldHover_ShowsImmediately_NoDwellWait() + { + var (root, presenter, requests) = CreateHarness(); + presenter.WorldHoverGuidProvider = () => WorldFoundGuid; + presenter.WorldHoverNameResolver = guid => guid == WorldFoundGuid ? "A Drudge" : null; + presenter.WorldTooltipsEnabled = () => true; + int childrenBefore = root.Children.Count; + + // A single Tick — no root.Tick dwell timer involved at all, unlike + // every UI-element case above. + presenter.Tick(); + + Assert.Equal(childrenBefore + 1, root.Children.Count); + Assert.Single(requests, r => r == (0x21000041u, 0x10000395u)); + } + + [Fact] + public void WorldHover_HidesWhenTheFoundGuidClears() + { + var (root, presenter, _) = CreateHarness(); + uint? found = WorldFoundGuid; + presenter.WorldHoverGuidProvider = () => found; + presenter.WorldHoverNameResolver = _ => "A Drudge"; + presenter.WorldTooltipsEnabled = () => true; + int childrenBefore = root.Children.Count; + + presenter.Tick(); + Assert.Equal(childrenBefore + 1, root.Children.Count); + + found = null; + presenter.Tick(); + + Assert.Equal(childrenBefore, root.Children.Count); + } + + [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; + int childrenBefore = root.Children.Count; + + presenter.Tick(); + + Assert.Empty(requests); + Assert.Equal(childrenBefore, root.Children.Count); + } + + [Fact] + public void WorldHover_NoNameResolved_ShowsNothing() + { + var (_, presenter, requests) = CreateHarness(); + presenter.WorldHoverGuidProvider = () => WorldFoundGuid; + presenter.WorldHoverNameResolver = _ => null; + presenter.WorldTooltipsEnabled = () => true; + + presenter.Tick(); + + Assert.Empty(requests); + } + + [Fact] + public void WorldHover_SuppressedWhileHoveringAUiElement() + { + // FindObject @0x004E5430: m_pElementLastOver != null routes through + // the UI-item special case or falls through to the 3D raycast — + // 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 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; + + presenter.Tick(); + + Assert.Empty(requests); + } + + [Fact] + public void WorldHover_ReEvaluatesGateAndTextOnlyOnTheFoundGuidEdge() + { + // 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(); + int gateReads = 0, nameReads = 0; + presenter.WorldHoverGuidProvider = () => WorldFoundGuid; + presenter.WorldHoverNameResolver = _ => { nameReads++; return "A Drudge"; }; + presenter.WorldTooltipsEnabled = () => { gateReads++; return true; }; + + presenter.Tick(); + presenter.Tick(); + presenter.Tick(); + + Assert.Equal(1, gateReads); + Assert.Equal(1, nameReads); + Assert.Single(requests); + } + + [Fact] + public void WorldHover_TextIsPlainAppropriateName_NoStackCountPrefix() + { + // RecvNotice_SmartBoxObjectFound's own text-building block + // (@0x004E5D3B-@0x004E5D74) has no "%d %s" stack-count logic — + // unlike UIElement_UIItem::UpdateTooltip's item-cell tooltip. The + // resolver contract here is plain GetAppropriateName, not + // GetTooltipDisplayName; this pin just documents the caller's + // 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; + + presenter.Tick(); + + UiElement popup = Assert.Single(root.Children); + UiElement? textChild = FindById(popup, TextChildId); + Assert.IsType(textChild); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs index 19c7a2fa..326b618b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/TooltipLiveDatTests.cs @@ -217,6 +217,125 @@ public sealed class TooltipLiveDatTests private const uint OptionsToggleRowTemplateId = 0x10000218u; private const uint OptionsToggleCheckboxId = 0x10000219u; + /// Retail's UIElement_UIItem registered class id + /// (UIElement_UIItem::Register @0x0047A488: + /// RegisterElementClass(0x10000032, ...)). The catalog also holds a + /// handful of type-3 (plain UIRegion) housekeeping elements used only as + /// BaseElement bases for the real prototypes below — never selected by any + /// list's own cell-template attribute, so they are excluded from this + /// scan. + private const uint UiItemElementType = 0x10000032u; + + /// + /// Item-tooltip investigation (docs/ISSUES.md #409 follow-on): pins the + /// finding that justifies UiItemSlot hardcoding a single popup- + /// locator pair rather than reading it per prototype. Every standalone + /// UIItem prototype (type 0x10000032) in the shared cell-template + /// catalog (, LayoutDesc + /// 0x21000037) resolves the SAME P0x47/P0x48 pair through + /// catalog inheritance — 0x10000395 within 0x21000041, one of + /// the four popup skins + /// already pins. None author literal P0x49 text or the P0x4B + /// on-bit — matching retail's runtime-text game-code sites + /// (UIElement_UIItem::UpdateTooltip @0x004E1CB0 sets both the text + /// and the on-bit itself; see 's + /// fromRuntime bypass). + /// + [InstalledDatFact] + public void UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + ElementInfo? catalog = LayoutImporter.ImportInfos(dats, ItemListCellTemplate.CatalogLayoutId); + Assert.NotNull(catalog); + Assert.True(catalog!.Children.Count >= 40, + $"expected the shared UIItem catalog to hold dozens of prototypes, found {catalog.Children.Count}."); + + var prototypes = catalog.Children.Where(c => c.Type == UiItemElementType).ToList(); + Assert.True(prototypes.Count >= 30, + $"expected the shared UIItem catalog to hold dozens of type-0x10000032 prototypes, found {prototypes.Count}."); + + foreach (ElementInfo prototype in prototypes) + { + Assert.True(prototype.TooltipRootElementId == UiItemTooltipRootElementId, + $"prototype 0x{prototype.Id:X8} authors P0x47=0x{prototype.TooltipRootElementId:X8}, expected 0x{UiItemTooltipRootElementId:X8}."); + Assert.True(prototype.TooltipLayoutDid == TooltipCatalogLayoutId, + $"prototype 0x{prototype.Id:X8} authors P0x48=0x{prototype.TooltipLayoutDid:X8}, expected 0x{TooltipCatalogLayoutId:X8}."); + Assert.False(prototype.TooltipText.HasValue, + $"prototype 0x{prototype.Id:X8} unexpectedly authors literal P0x49 tooltip text."); + } + + // Concrete owning lists select DIFFERENT prototype ids (attribute + // 0x1000000E) for their own cell shape, but every one of those + // prototypes still resolves the same popup locator above — so + // UiItemSlot's hardcoded pair is correct regardless of which list + // spawned the cell. + ElementInfo? inventoryTree = LayoutImporter.ImportInfos(dats, 0x21000023u); + ElementInfo? contentsGrid = inventoryTree is null + ? null : AllDescendants(inventoryTree).FirstOrDefault(x => x.Id == 0x100001C6u); + Assert.NotNull(contentsGrid); + Assert.True(contentsGrid!.TryGetEffectiveProperty(0x1000000Eu, out UiPropertyValue protoProp)); + Assert.NotEqual(0u, (uint)protoProp.UnsignedValue); + + ElementInfo? toolbarTree = LayoutImporter.ImportInfos(dats, 0x21000016u); + ElementInfo? toolbarSlot = toolbarTree is null + ? null : AllDescendants(toolbarTree).FirstOrDefault(x => x.Id == 0x100001A7u); + Assert.NotNull(toolbarSlot); + Assert.True(toolbarSlot!.TryGetEffectiveProperty(0x1000000Eu, out UiPropertyValue toolbarProtoProp)); + Assert.NotEqual(0u, (uint)toolbarProtoProp.UnsignedValue); + // Different lists really do select different prototypes. + Assert.NotEqual((uint)protoProp.UnsignedValue, (uint)toolbarProtoProp.UnsignedValue); + } + + /// + /// World-object tooltip investigation (docs/ISSUES.md #409 follow-on). + /// UIElement_SmartBoxWrapper (retail's registered class + /// 0x10000030, UIElement_SmartBoxWrapper::Register @0x0047A47E) + /// is the caller of the world-hover tooltip's own + /// UIElement::SetTooltip/StartTooltipAtMouse pair + /// (RecvNotice_SmartBoxObjectFound @0x004E5AD0, calls at + /// @0x004E5D74/@0x004E5DFB) — but this exhaustive sweep of + /// every installed LayoutDesc found ZERO elements of that type + /// anywhere. Unlike the UIItem catalog (49 standalone template + /// prototypes, all authoring the SAME popup locator), the 3D-viewport + /// wrapper is evidently constructed directly by game code + /// (gmGamePlayUI's own mode setup) rather than from a walkable + /// authored ElementDesc, so its own P0x47/P0x48 + /// cannot be read from the DAT the way every other tooltip trigger's + /// can. 's world-hover popup therefore + /// REUSES the item-catalog's confirmed uniform pair + /// (P0x47=0x10000395/P0x48=0x21000041) — the SAME "generic + /// runtime-text" skin every other game-code SetTooltip caller in + /// this family (items, the Options checkboxes, the radar) draws from — + /// as the best-evidenced inference rather than leaving world-object + /// tooltips unimplemented over one unrecoverable hex constant. + /// + [InstalledDatFact] + public void SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + int found = 0; + foreach (uint layoutId in dats.GetAllIdsOfType()) + { + ElementInfo? tree; + try { tree = LayoutImporter.ImportInfos(dats, layoutId); } + catch { continue; } + if (tree is null) continue; + + found += AllDescendants(tree).Count(e => e.Type == SmartBoxWrapperElementType); + } + + Assert.Equal(0, found); + } + + /// Retail's UIElement_SmartBoxWrapper registered class id. + private const uint SmartBoxWrapperElementType = 0x10000030u; + + /// The item-cell popup-locator P0x47, hardcoded onto every + /// — see that class's own doc comment. + private const uint UiItemTooltipRootElementId = 0x10000395u; + private static IEnumerable AllWidgets(UiElement root) { yield return root; From aed423174b1c12f85b7b5a45f40d31104cd8e3cf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 23:02:42 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(ui):=20#411=20=E2=80=94=20pointer=20swa?= =?UTF-8?q?ps=20over=20inventory=20items=20unconditionally,=20not=20only?= =?UTF-8?q?=20in=20UseTarget=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects an incomplete reading from #411's original investigation. UIElement_SmartBoxWrapper::FindObject @0x004E5430 calls SmartBox::set_found_object(itemID, 0xFFFFFFFF) whenever the hovered UI element (m_pElementLastOver) casts to UIElement_UIItem (class 0x10000032) — UNCONDITIONALLY, not gated on target mode, and returns WITHOUT running the 3D raycast. ClientUISystem:: UpdateCursorState @0x00564630 computes its "found" flag ONCE at the top of the function (ebx = SmartBox::get_found_object_id() != 0, @0x00564642) and every later branch (default/melee-missile/magic/ use/examine/use-target/busy) reads that SAME flag — so hovering an occupied item cell shows the cursor's "...Found" variant in EVERY mode, not only during an active UseTarget selection. CursorFeedbackController.Update(UiRoot) already had the item-hover special case wired from an earlier round but incorrectly gated it to TargetMode.UseTarget only; that one-line gate is removed. ResolveGlobalKind needed no changes at all — it already read the snapshot's HoverTargetGuid unconditionally across every mode. Two new tests pin the widened behavior in ordinary peace mode and in combat mode. Live-DAT-independent (pure decomp + unit fixture). Co-Authored-By: Claude Fable 5 --- .../UI/CursorFeedbackController.cs | 31 +++++++++---- .../UI/CursorFeedbackControllerTests.cs | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index 46b04511..1c98994b 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -109,17 +109,32 @@ public sealed class CursorFeedbackController UiElement? hover = root.Pick(root.MouseX, root.MouseY); - // Retail UpdateCursorState (0x00564630) keys the target-mode cursor off - // the SmartBox found object — the WORLD entity under the cursor. A UI - // window occludes the world (no found object → pending). The one - // UI-side source retail-style cells contribute is an occupied item - // slot's own item. + // Retail UpdateCursorState (0x00564630) keys EVERY mode's cursor off + // the SAME SmartBox found-object flag, computed once at the top of + // the function (ebx = SmartBox::get_found_object_id() != 0, + // @0x00564642) and read verbatim by every later branch — target mode + // and combat mode only pick WHICH cursor variant (Default vs. + // DefaultFound, Use vs. UseFound, ...) to show for that SAME found + // state, never whether it is set. + // + // #411 correction (2026-08-16): the found object itself is not + // world-only. UIElement_SmartBoxWrapper::FindObject @0x004E5430 runs + // every frame regardless of input focus (Global_Loop @0x004E5620) + // and, when the currently-hovered UI element (m_pElementLastOver) + // casts to UIElement_UIItem (0x10000032), calls + // SmartBox::set_found_object(itemID) directly — UNCONDITIONALLY, not + // gated on target mode — and returns WITHOUT running the 3D raycast. + // So hovering an occupied item cell sets the SAME found flag in + // EVERY mode (peace, melee/missile, magic, busy, examine, use, + // use-target), which is why retail's cursor visibly changes there — + // the earlier reading here (item slots only contribute in + // UseTarget) covered only the Valid/Invalid sub-branch, not the + // found flag driving the Default/Combat/Use/Examine/Busy Found + // variants too. RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction); uint hoverTarget = hover is null ? _worldTargetProvider?.Invoke() ?? 0u - : targetMode == RetailCursorTargetMode.UseTarget - ? FindHoveredItemSlot(hover)?.ItemId ?? 0u - : 0u; + : FindHoveredItemSlot(hover)?.ItemId ?? 0u; bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget && hoverTarget != 0 ? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget) diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 3a18783f..8239b1ad 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -308,6 +308,51 @@ public sealed class CursorFeedbackControllerTests Assert.False(feedback.Cursor.IsValid); } + /// + /// #411 fix (2026-08-16): UIElement_SmartBoxWrapper::FindObject + /// @0x004E5430 calls SmartBox::set_found_object(itemID) whenever the + /// hovered UI element casts to UIElement_UIItem, UNCONDITIONALLY — not + /// only in TARGET_MODE_USE_TARGET. ClientUISystem::UpdateCursorState + /// @0x00564630 then reads that SAME found flag from every mode branch + /// (Default/Combat/Use/Examine/Busy), so hovering an occupied item cell + /// in ordinary peace mode (no active interaction mode at all) must show + /// the Found cursor variant. This is the earlier-STOPped "does retail + /// swap the pointer over inventory items" question, resolved: yes, + /// unconditionally. + /// + [Fact] + public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InOrdinaryPeaceMode() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 }; + slot.SetItem(Target, iconTexture: 1u); + root.AddChild(slot); + root.OnMouseMove(20, 20); + var c = new CursorFeedbackController(); // no ItemInteractionController — TargetMode.None throughout + + var feedback = c.Update(root); + + Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind); + } + + /// Same #411 fix, combat mode: the found flag is mode- + /// independent, so an item hover under an active combat stance shows + /// MeleeOrMissileFound, not the plain (not-found) MeleeOrMissile. + [Fact] + public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InCombatMode() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 }; + slot.SetItem(Target, iconTexture: 1u); + root.AddChild(slot); + root.OnMouseMove(20, 20); + var c = new CursorFeedbackController(combatModeProvider: () => CombatMode.Melee); + + var feedback = c.Update(root); + + Assert.Equal(RetailGlobalCursorKind.MeleeOrMissileFound, feedback.GlobalKind); + } + [Fact] public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered() { From 708e35f610373d45342c2000f9d39f344e783fee Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 23:02:50 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20hover-feedback=20completion=20round?= =?UTF-8?q?=20=E2=80=94=20#409/#411=20bookkeeping,=20TS-85=20narrowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the round that shipped item-cell tooltips, the world-object hover tooltip, and the #411 cursor-swap fix: #409's write-up gains a "hover-feedback completion round" section covering all three items with live-verification notes; #411 is closed with the corrected decomp reading; register row TS-85 is narrowed to reflect the two newly-ported SetTooltip call sites (UIElement_UIItem::UpdateTooltip, UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound) and the still-open ones (spellcasting endowment/cast-button/favorite/submenu, map notes, character-panel attribute/skill info). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 133 ++++++++++++++++-- .../retail-divergence-register.md | 2 +- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e636df02..bd214cec 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,13 +26,33 @@ What does NOT go here: ## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state -**Status:** OPEN (filed 2026-08-16 during the #409 live-failure investigation, at the -lead's scope addition; user report: "the cursor should light up when I hover over an -item in inventory. it does not") +**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the +user's answer to the open question below ("the POINTER changes, like it already +does over world NPCs") is CONFIRMED by decomp, not merely a memory to trust: +`UIElement_SmartBoxWrapper::FindObject @0x004E5430` calls +`SmartBox::set_found_object(itemID)` UNCONDITIONALLY whenever the hovered UI +element casts to `UIElement_UIItem` — not gated on target mode, as the port plan +below (written before this finding) assumed it would need to be. Fixed with a +ONE-LINE widening of `CursorFeedbackController.Update(UiRoot)`'s existing +(too-narrow) item-hover special case; `ResolveGlobalKind` needed no changes, +since it already read the "found" flag unconditionally across every mode. See +docs/ISSUES.md #409's own "hover-feedback completion round" write-up (item 3) +for the full derivation and `CursorFeedbackControllerTests. +UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_In{OrdinaryPeaceMode,CombatMode}` +for the pin. The rollover-STATE half of this investigation (item 3 below, +`UiItemSlot` has no `HoverEnter`/`HoverLeave`) was NOT in scope for the pointer +question and remains unaddressed if the user separately wants the highlight — +file a fresh issue if so; this closure covers only the pointer-swap question the +lead's scope addition asked about. + **Severity:** LOW (cosmetic/affordance; no gameplay impact) **Depends on:** nothing — the hover dispatch it needs is already correct (see #409's live-failure round, which proved `UiRoot.UpdateHover` selects the right widget). +**Original investigation (below), kept for its still-valid layer 1/2/3 breakdown — +only the "never fires over an inventory item" conclusion for layer 2 was +incomplete; see the closure note above for the corrected reading.** + **Retail mechanism, derived from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.** There are THREE separate hover-feedback layers, and the DAT decides which one applies: @@ -188,7 +208,7 @@ horizontal `HJustify` mapping while in this code, since it shares the ## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) -**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11 and the LIVE-FAILURE round both landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the user's full connected gate is still owed. +**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11, the LIVE-FAILURE round, and the hover-feedback completion round (item-cell tooltips + world-object hover tooltip) all landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the hover-feedback completion round's three items are automated-gate-verified (unit + live-DAT) but the user's connected gate for THOSE items specifically is still owed — see that round's own "Live-verify all three" note. **Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix) **2026-08-16 re-derivation + port.** Full re-derivation from @@ -353,16 +373,113 @@ the "Slots" button. spin arrows — longer text, should WRAP rather than run off-screen; (7) the Heritage/Profession/Skills/Town/Summary tab buttons. -*Expected NOT to show anything yet (deferred, register TS-85):* hovering an -inventory ITEM icon. Retail shows the item name there -(`UIElement_UIItem::UpdateTooltip @0x004E1CB0`); acdream shows nothing. - Confirm also: the box sits offset down-right of the cursor (retail's +32px on both axes), never runs off the edge of the window even near a corner, and disappears on its own after ~10 s if you hold still without moving away. No click-to-dismiss is expected — only moving off the control, or a very long hold, closes it. +**2026-08-16 hover-feedback completion round.** Closes the two items the +live-failure round explicitly deferred (item-cell tooltips, and the #411 +pointer question), plus the world-object hover tooltip the user's gate notes +called out separately. + +1. **Inventory/shortcut/paperdoll item-name tooltips — SHIPPED.** + `UIElement_UIItem::UpdateTooltip @0x004E1CB0` is called from + `UIItem_Update` (an item-DATA-CHANGE refresh, not a hover handler — the + trigger that actually SHOWS it is the generic `CheckTooltip` dwell timer, + same as any other tooltip-bearing element). Re-derived and closed the gap + the live-failure round left open ("acdream's `UiItemSlot` carries neither + the `P0x47` popup locator nor a name source"): live-DAT sweep of the + shared UIItem cell-template catalog (`ItemListCellTemplate.CatalogLayoutId`, + `0x21000037`) found ALL 47 UIItem-type (class `0x10000032`) prototypes — + inventory's cell, every toolbar slot, every paperdoll/armor slot skin — + resolve the IDENTICAL popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`) + through catalog inheritance, with no literal text authored on any of them + (`TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`). + `UiItemSlot` now hardcodes that pair and exposes `GetTooltipText()` via a + new per-instance `TooltipTextResolve` delegate, wired at every physical- + item construction site — `InventoryController` (main-pack cell + grid + cells), `ExternalContainerController`, `PaperdollController` (closes the + `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row too — same cell + class, same fix), `VendorUiController` (shop/buying/selling lists), + `SecureTradeUiController`, `ToolbarController`. Text is + `ClientObject.GetTooltipDisplayName()` (new Core method): `GetAppropriateName()` + prefixed with the stack count via `"{count} {name}"` when `StackSize > 1`, + matching `UpdateTooltip`'s exact `NAME_APPROPRIATE` + `"%d %s"` sprintf. + `UiCatalogSlot` (spell/component catalog cells — a DIFFERENT `UiItemSlot` + subclass) is unaffected; it already overrides `GetTooltipText()` with its + own `Label`. + +2. **World-object hover tooltip (NPCs, players, signs, chests, portals) — + SHIPPED.** NOT the UI-element dwell-timer path — retail's mechanism is + `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`, + fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620` + using the current mouse position regardless of input focus. It fires + IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the + `PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips` + — already modeled in `CharacterOptionTable`, default true), with text + `ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name + call as item tooltips, but WITHOUT the item-cell's separate stack-count + prefix (a real, decomp-confirmed asymmetry: a ground pile of arrows shows + "Arrows", not "20 Arrows"). Ported as `RetailTooltipPresenter. + UpdateWorldHoverTooltip`, driven by the SAME world-hover pick + `CursorFeedbackController`'s own found-cursor already uses + (`WorldSelectionQuery.PickAtCursor`, `includeSelf: true`) and the SAME + `ClientObjectTable`-backed name resolver `SocialAllegiancePageController`'s + `ResolveWorldObjectName` already established as this codebase's pattern. + Queried only when no UI element is hovered (this port's reading of + `FindObject`'s `m_pElementLastOver` check, narrowed from retail's literal + "raycast even under non-item UI chrome" — see the class's own doc note). + **Own player is included** (`includeSelf: true`, the same precedent the + cursor feedback wiring already set) — no decomp evidence was found either + confirming or excluding self from the found-object pipeline, so this + follows the established local precedent rather than guessing fresh; flag + if that reads wrong in the live gate. **The exact popup skin is an + inference, not a measured value** — an exhaustive live-DAT sweep found + `UIElement_SmartBoxWrapper` (class `0x10000030`) has NO authored + `ElementDesc` anywhere installed (unlike every other tooltip trigger, it + is evidently constructed directly by `gmGamePlayUI`'s own mode setup, not + from a walkable LayoutDesc — `TooltipLiveDatTests. + SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`), so its real + `P0x47`/`P0x48` cannot be read off the DAT the way the item catalog's can. + This port reuses the SAME pair every other game-code `SetTooltip` caller + in this family resolves to — the best-evidenced choice, called out in + register row TS-85 rather than silently assumed exact. + +3. **#411 resolved: retail DOES swap the pointer over inventory items, + unconditionally — the earlier investigation's "never fires over an + inventory item" finding was INCOMPLETE, not wrong about what it checked.** + The original #411 scan (below) correctly found no PER-ELEMENT authored + cursor on item cells and correctly found `SmartBox::get_found_object_id()` + is written only by `UIElement_SmartBoxWrapper` — but it had not yet traced + `FindObject @0x004E5430` far enough: when the currently-hovered UI element + (`m_pElementLastOver`) casts to `UIElement_UIItem` (class `0x10000032`), + `FindObject` calls `SmartBox::set_found_object(itemID, 0xFFFFFFFF)` + directly and returns WITHOUT running the 3D raycast — UNCONDITIONALLY, not + gated on target mode. `ClientUISystem::UpdateCursorState @0x00564630` + computes its "found" flag ONCE at the top of the function + (`ebx = SmartBox::get_found_object_id() != 0`, `@0x00564642`) and every + later branch (default/melee-missile/magic/use/examine/use-target/busy) + reads that SAME flag — so hovering an occupied item cell shows the + cursor's "...Found" variant in EVERY mode, not only during an active + `UseTarget` selection. `CursorFeedbackController.Update(UiRoot)` already + had the item-hover special case wired (from an earlier round) but + incorrectly gated it to `TargetMode.UseTarget` only; that one-line gate is + now removed — `ResolveGlobalKind`'s existing found/not-found branching + needed no changes at all, since it already read the snapshot's + `HoverTargetGuid` unconditionally across every mode. Live-DAT-independent + (pure decomp + unit fixture), so no DAT sweep was needed for this part; + two new `CursorFeedbackControllerTests` pin the widened behavior in + ordinary peace mode and in combat mode. + +**Live-verify all three on the connected client** (session-config launch, +graceful close per the usual rules): hover an inventory item — a name +tooltip should appear (with a count prefix for a stack) AND the mouse +pointer should swap to its "found" variant; hover an NPC/creature — a name +tooltip should appear immediately (no perceptible delay) if "Show Tooltips" +is on; hover a sign/chest/portal similarly. + --- **Original GF-16 filing (superseded by the re-derivation above; kept for diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index aacf570c..13e9c959 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -410,7 +410,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. The 15 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; NO ACDREAM ANALOG YET — inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` (retail shows the item NAME, quantity-prefixed `"%d %s"` when stack > 1; measured live 2026-08-16 to show nothing in acdream, and the highest-value remaining gap since acdream's `UiItemSlot` is constructed programmatically and so carries neither `P0x47` nor a name source), the paperdoll equip slots `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF`, the spellcasting endowment icon `@0x004C63A1` / cast button `@0x004C6FE8` / favorite `@0x004C7206` / submenu `@0x004C67D8`, the map notes `gmMapUI::AddMapNote @0x004A1C51` (no acdream map UI), the SmartBox found-object `@0x004E5D74`, and the character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors. Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`) | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. The 15 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its edge-fired (no dwell), `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. STILL NO ACDREAM ANALOG — the spellcasting endowment icon `@0x004C63A1` / cast button `@0x004C6FE8` / favorite `@0x004C7206` / submenu `@0x004C67D8` (all `UiCatalogSlot`-based, which already has its own independent `Label`-driven `GetTooltipText` — a real gap only if that Label wiring turns out incomplete, unaudited this round), the map notes `gmMapUI::AddMapNote @0x004A1C51` (no acdream map UI), and the character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors. Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`) | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) |