Merge campaign-409-tooltips: hover-feedback completion — item names, world-object tooltips, item cursor swap
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Item-name tooltips on every UiItemSlot surface (UIElement_UIItem::UpdateTooltip @0x004E1CB0, stack-count prefix, the 47-prototype shared popup locator); world-object hover tooltips via retail's SmartBox found-object pipeline (RecvNotice_SmartBoxObjectFound @0x004E5AD0 — immediate, ShowTooltips-gated, no stack prefix — live-verified: 'Silver Tusker' + DefaultFound cursor from one pipeline); #411 CLOSED — retail's set_found_object fires unconditionally over UI items, the user's memory was right, our target-mode gate was wrong. TS-85 narrowed to the enumerated remainder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
63b0668fb9
17 changed files with 717 additions and 28 deletions
133
docs/ISSUES.md
133
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
|
## #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
|
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
|
||||||
lead's scope addition; user report: "the cursor should light up when I hover over an
|
user's answer to the open question below ("the POINTER changes, like it already
|
||||||
item in inventory. it does not")
|
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)
|
**Severity:** LOW (cosmetic/affordance; no gameplay impact)
|
||||||
**Depends on:** nothing — the hover dispatch it needs is already correct (see #409's
|
**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).
|
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`.**
|
**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:
|
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)
|
## #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)
|
**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
|
**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
|
spin arrows — longer text, should WRAP rather than run off-screen; (7) the
|
||||||
Heritage/Profession/Skills/Town/Summary tab buttons.
|
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
|
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
|
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
|
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
|
click-to-dismiss is expected — only moving off the control, or a very long
|
||||||
hold, closes it.
|
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
|
**Original GF-16 filing (superseded by the re-derivation above; kept for
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -842,6 +842,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
d.Actions.Selection,
|
d.Actions.Selection,
|
||||||
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
|
||||||
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
|
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(
|
Confirmations: new ConfirmationRuntimeBindings(
|
||||||
(type, context, accepted) =>
|
(type, context, accepted) =>
|
||||||
late.Session.CurrentSession?.SendConfirmationResponse(
|
late.Session.CurrentSession?.SendConfirmationResponse(
|
||||||
|
|
|
||||||
|
|
@ -109,17 +109,32 @@ public sealed class CursorFeedbackController
|
||||||
|
|
||||||
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
||||||
|
|
||||||
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off
|
// Retail UpdateCursorState (0x00564630) keys EVERY mode's cursor off
|
||||||
// the SmartBox found object — the WORLD entity under the cursor. A UI
|
// the SAME SmartBox found-object flag, computed once at the top of
|
||||||
// window occludes the world (no found object → pending). The one
|
// the function (ebx = SmartBox::get_found_object_id() != 0,
|
||||||
// UI-side source retail-style cells contribute is an occupied item
|
// @0x00564642) and read verbatim by every later branch — target mode
|
||||||
// slot's own item.
|
// 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);
|
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
|
||||||
uint hoverTarget = hover is null
|
uint hoverTarget = hover is null
|
||||||
? _worldTargetProvider?.Invoke() ?? 0u
|
? _worldTargetProvider?.Invoke() ?? 0u
|
||||||
: targetMode == RetailCursorTargetMode.UseTarget
|
: FindHoveredItemSlot(hover)?.ItemId ?? 0u;
|
||||||
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
|
|
||||||
: 0u;
|
|
||||||
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
|
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
|
||||||
&& hoverTarget != 0
|
&& hoverTarget != 0
|
||||||
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
|
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
||||||
SpriteResolve = owner.SpriteResolve,
|
SpriteResolve = owner.SpriteResolve,
|
||||||
SlotIndex = owner.GetNumUIItems(),
|
SlotIndex = owner.GetNumUIItems(),
|
||||||
SourceKind = source,
|
SourceKind = source,
|
||||||
|
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
|
||||||
};
|
};
|
||||||
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
|
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
|
||||||
return cell;
|
return cell;
|
||||||
|
|
|
||||||
|
|
@ -444,7 +444,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
{
|
{
|
||||||
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
|
const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate)
|
||||||
_topContainer.Flush();
|
_topContainer.Flush();
|
||||||
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
|
var main = new UiItemSlot
|
||||||
|
{
|
||||||
|
SpriteResolve = _topContainer.SpriteResolve,
|
||||||
|
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
|
||||||
|
};
|
||||||
main.SetItem(
|
main.SetItem(
|
||||||
p,
|
p,
|
||||||
_iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u),
|
_iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u),
|
||||||
|
|
@ -486,7 +490,11 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
uint dragTex = item is null ? 0u
|
uint dragTex = item is null ? 0u
|
||||||
: _dragIconIds?.Invoke(
|
: _dragIconIds?.Invoke(
|
||||||
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
|
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.SetItem(guid, tex, dragIconTexture: dragTex);
|
||||||
cell.SetWaitingState(waiting);
|
cell.SetWaitingState(waiting);
|
||||||
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
||||||
list.ExamineItemRequested = ExamineItem;
|
list.ExamineItemRequested = ExamineItem;
|
||||||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||||
list.Cell.SlotIndex = i; // definition position = equipped drag-payload SourceSlot
|
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
|
list.Cell.EmptySprite = emptySlotSprites is not null
|
||||||
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite)
|
&& emptySlotSprites.TryGetValue(element, out uint authoredSprite)
|
||||||
? authoredSprite
|
? authoredSprite
|
||||||
|
|
|
||||||
|
|
@ -173,20 +173,36 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
if (layoutDid == 0u)
|
if (layoutDid == 0u)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (TryBuildAndMountPopup(widget.AuthoredTooltipRootElementId, layoutDid, tooltipText!))
|
||||||
|
_owner = widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared popup-build/mount body for BOTH tooltip triggers this class
|
||||||
|
/// owns: the UI-element hover-dwell path above (<see cref="OnTooltipShow"/>,
|
||||||
|
/// per-widget authored <c>P0x47</c>/<c>P0x48</c>) and the world-object
|
||||||
|
/// hover path below (<see cref="UpdateWorldHoverTooltip"/>, the fixed
|
||||||
|
/// popup-skin pair every game-code <c>SetTooltip</c> caller in this
|
||||||
|
/// family resolves to). Extracted unchanged from the pre-#411-follow-on
|
||||||
|
/// <c>OnTooltipShow</c> body — same F4/F5/F8 fixes, same failure
|
||||||
|
/// handling.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryBuildAndMountPopup(uint rootElementId, uint layoutDid, string tooltipText)
|
||||||
|
{
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
layout = _createLayout(layoutDid, widget.AuthoredTooltipRootElementId);
|
layout = _createLayout(layoutDid, rootElementId);
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
|
$"[UI] #409 tooltip popup layout=0x{layoutDid:X8} "
|
||||||
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
|
+ $"root=0x{rootElementId:X8} failed to build: {error.Message}");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (layout is null)
|
if (layout is null)
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
UiElement root = layout.Root;
|
UiElement root = layout.Root;
|
||||||
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
|
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
|
// show an empty, unsized 30x30 bevel artifact instead of retail's
|
||||||
// silent no-op.
|
// silent no-op.
|
||||||
if (textChild is not UiText text)
|
if (textChild is not UiText text)
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
// F4: null the per-frame anchor recompute on BOTH the popup root and
|
// F4: null the per-frame anchor recompute on BOTH the popup root and
|
||||||
// its text child before resizing, the same shape the sibling
|
// its text child before resizing, the same shape the sibling
|
||||||
|
|
@ -216,7 +232,7 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
text.LayoutPolicy = null;
|
text.LayoutPolicy = null;
|
||||||
text.Anchors = AnchorEdges.None;
|
text.Anchors = AnchorEdges.None;
|
||||||
|
|
||||||
ApplyTooltipText(root, text, tooltipText!);
|
ApplyTooltipText(root, text, tooltipText);
|
||||||
|
|
||||||
SetClickThroughRecursive(root);
|
SetClickThroughRecursive(root);
|
||||||
PositionAtMouse(root);
|
PositionAtMouse(root);
|
||||||
|
|
@ -224,7 +240,7 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
_host.AddChild(root);
|
_host.AddChild(root);
|
||||||
_host.BringToFront(root);
|
_host.BringToFront(root);
|
||||||
_popupRoot = root;
|
_popupRoot = root;
|
||||||
_owner = widget;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTooltipHide(UiElement widget)
|
private void OnTooltipHide(UiElement widget)
|
||||||
|
|
@ -240,6 +256,122 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
_host.RemoveChild(_popupRoot);
|
_host.RemoveChild(_popupRoot);
|
||||||
_popupRoot = null;
|
_popupRoot = null;
|
||||||
_owner = 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.
|
||||||
|
|
||||||
|
/// <summary>Same popup skin every UIItem prototype resolves to
|
||||||
|
/// (<see cref="UiItemSlot"/>'s own <c>ItemTooltipRootElementId</c>) —
|
||||||
|
/// see this section's own doc note on why the exact value cannot be
|
||||||
|
/// read off an authored <c>UIElement_SmartBoxWrapper</c> ElementDesc.</summary>
|
||||||
|
private const uint WorldPopupRootElementId = 0x10000395u;
|
||||||
|
private const uint WorldPopupLayoutDid = 0x21000041u;
|
||||||
|
|
||||||
|
private uint _worldHoverGuid;
|
||||||
|
private bool _worldTooltipShowing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The world-hover pick (retail's <c>SmartBox::find_object</c> via
|
||||||
|
/// <c>UIElement_SmartBoxWrapper::FindObject @0x004E5430</c>'s fallback
|
||||||
|
/// branch) — a per-frame "what's under the cursor right now" query,
|
||||||
|
/// distinct from click-driven <c>SelectionState</c>. Queried only when
|
||||||
|
/// <see cref="UiRoot.Pick"/> finds no UI element under the cursor
|
||||||
|
/// (mirrors <c>FindObject</c>'s <c>m_pElementLastOver</c> 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).
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint?>? WorldHoverGuidProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)</c> —
|
||||||
|
/// 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 <c>@0x004E5D48</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint, string?>? WorldHoverNameResolver { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>PlayerModule::ShowTooltips</c> (<c>CharacterOptionId.ShowTooltips</c>) —
|
||||||
|
/// read once per found-object edge, exactly where retail reads it
|
||||||
|
/// (<c>@0x004E5D21</c>). 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.
|
||||||
|
/// </summary>
|
||||||
|
public Func<bool>? 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Force-hides whatever tooltip is currently showing, if any.
|
/// <summary>Force-hides whatever tooltip is currently showing, if any.
|
||||||
|
|
@ -371,6 +503,8 @@ public sealed class RetailTooltipPresenter : IDisposable
|
||||||
{
|
{
|
||||||
if (_popupRoot is not null)
|
if (_popupRoot is not null)
|
||||||
_host.BringToFront(_popupRoot);
|
_host.BringToFront(_popupRoot);
|
||||||
|
|
||||||
|
UpdateWorldHoverTooltip();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,7 @@ public sealed class SecureTradeUiController : IRetainedPanelController
|
||||||
// Your staged items carry retail's trading marker.
|
// Your staged items carry retail's trading marker.
|
||||||
ShowTradeOverlay = side == RuntimeTradeSide.Self,
|
ShowTradeOverlay = side == RuntimeTradeSide.Self,
|
||||||
TradeOverlaySprite = TradeOverlaySpriteId,
|
TradeOverlaySprite = TradeOverlaySpriteId,
|
||||||
|
TooltipTextResolve = g => _bindings.Objects.Get(g)?.GetTooltipDisplayName(),
|
||||||
};
|
};
|
||||||
cell.SetItem(guid, icon);
|
cell.SetItem(guid, icon);
|
||||||
list.AddItem(cell);
|
list.AddItem(cell);
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
||||||
list.Cell.SlotIndex = i;
|
list.Cell.SlotIndex = i;
|
||||||
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
|
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
|
||||||
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
|
||||||
|
list.Cell.TooltipTextResolve = g => _repo.Get(g)?.GetTooltipDisplayName();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1102,6 +1102,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
// comment for why this must be gated at the source,
|
// comment for why this must be gated at the source,
|
||||||
// not left to every destination handler to reject.
|
// not left to every destination handler to reject.
|
||||||
AllowDragSource = false,
|
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.SetItem(item.ItemGuid, icon);
|
||||||
cell.Selected = item.ItemGuid == selectedGuid;
|
cell.Selected = item.ItemGuid == selectedGuid;
|
||||||
|
|
@ -2205,6 +2209,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
SpriteResolve = list.SpriteResolve,
|
SpriteResolve = list.SpriteResolve,
|
||||||
SlotIndex = list.GetNumUIItems(),
|
SlotIndex = list.GetNumUIItems(),
|
||||||
AllowDragSource = false,
|
AllowDragSource = false,
|
||||||
|
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
|
||||||
};
|
};
|
||||||
cell.SetItem(shopItem.ItemGuid, icon);
|
cell.SetItem(shopItem.ItemGuid, icon);
|
||||||
cell.Selected = shopItem.ItemGuid == selectedGuid;
|
cell.Selected = shopItem.ItemGuid == selectedGuid;
|
||||||
|
|
@ -2243,6 +2248,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
SpriteResolve = list.SpriteResolve,
|
SpriteResolve = list.SpriteResolve,
|
||||||
SlotIndex = list.GetNumUIItems(),
|
SlotIndex = list.GetNumUIItems(),
|
||||||
AllowDragSource = false,
|
AllowDragSource = false,
|
||||||
|
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
|
||||||
};
|
};
|
||||||
cell.SetItem(item.ObjectId, icon);
|
cell.SetItem(item.ObjectId, icon);
|
||||||
cell.Selected = item.ObjectId == selectedGuid;
|
cell.Selected = item.ObjectId == selectedGuid;
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,17 @@ public sealed record RetailUiCursorBindings(
|
||||||
CursorFeedbackController Feedback,
|
CursorFeedbackController Feedback,
|
||||||
RetailCursorManager Manager);
|
RetailCursorManager Manager);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #409 follow-on (docs/ISSUES.md "Item 2"): world-object hover tooltip
|
||||||
|
/// bindings for <see cref="RetailTooltipPresenter"/>'s world-hover half — see
|
||||||
|
/// that class's own doc note on <c>UIElement_SmartBoxWrapper::
|
||||||
|
/// RecvNotice_SmartBoxObjectFound @0x004E5AD0</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record WorldTooltipRuntimeBindings(
|
||||||
|
Func<uint?> HoverGuidAtCursor,
|
||||||
|
Func<uint, string?> ResolveName,
|
||||||
|
Func<bool> Enabled);
|
||||||
|
|
||||||
public sealed record ConfirmationRuntimeBindings(
|
public sealed record ConfirmationRuntimeBindings(
|
||||||
Action<uint, uint, bool> SendResponse);
|
Action<uint, uint, bool> SendResponse);
|
||||||
|
|
||||||
|
|
@ -435,6 +446,7 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
ExternalContainerRuntimeBindings ExternalContainer,
|
ExternalContainerRuntimeBindings ExternalContainer,
|
||||||
VendorRuntimeBindings Vendor,
|
VendorRuntimeBindings Vendor,
|
||||||
RetailUiCursorBindings Cursor,
|
RetailUiCursorBindings Cursor,
|
||||||
|
WorldTooltipRuntimeBindings WorldTooltip,
|
||||||
ConfirmationRuntimeBindings Confirmations,
|
ConfirmationRuntimeBindings Confirmations,
|
||||||
AppraisalRuntimeBindings Appraisal,
|
AppraisalRuntimeBindings Appraisal,
|
||||||
OptionsRuntimeBindings Options,
|
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)
|
if (_bindings.Chat.Store is { } store)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,63 @@ namespace AcDream.App.UI;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UiItemSlot : UiElement
|
public class UiItemSlot : UiElement
|
||||||
{
|
{
|
||||||
public UiItemSlot() { ClickThrough = false; }
|
/// <summary>
|
||||||
|
/// Retail's shared UIItem cell-template catalog (<c>ItemListCellTemplate.
|
||||||
|
/// CatalogLayoutId</c>, LayoutDesc <c>0x21000037</c>) 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 <c>0x1000033A</c>, the toolbar's per-slot prototypes
|
||||||
|
/// <c>0x1000043B</c>.., the container cell <c>0x1000033F</c>, and every
|
||||||
|
/// paperdoll/armor slot skin alike) resolves <c>P0x47=0x10000395</c> /
|
||||||
|
/// <c>P0x48=0x21000041</c> through catalog inheritance, matching one of
|
||||||
|
/// the four popup skins <see cref="Layout.RetailTooltipPresenter"/> already
|
||||||
|
/// mounts for every other tooltip-bearing element
|
||||||
|
/// (<c>Layout.TooltipLiveDatTests.PopupSkinRootIds</c>). Since
|
||||||
|
/// <see cref="UiItemSlot"/> cells are built programmatically (never through
|
||||||
|
/// <c>LayoutImporter.Build</c>), this port hardcodes the uniform pair here
|
||||||
|
/// rather than re-deriving it per instance — the same "exhaustive scan,
|
||||||
|
/// then hardcode" shape as <c>RetailCursorCatalog</c>'s five window-control
|
||||||
|
/// cursor DIDs and <c>ItemListCellTemplate.CatalogLayoutId</c> itself.
|
||||||
|
/// </summary>
|
||||||
|
private const uint ItemTooltipRootElementId = 0x10000395u;
|
||||||
|
private const uint ItemTooltipLayoutDid = 0x21000041u;
|
||||||
|
|
||||||
|
public UiItemSlot()
|
||||||
|
{
|
||||||
|
ClickThrough = false;
|
||||||
|
AuthoredTooltipRootElementId = ItemTooltipRootElementId;
|
||||||
|
AuthoredTooltipLayoutDid = ItemTooltipLayoutDid;
|
||||||
|
}
|
||||||
|
|
||||||
public override bool ConsumesDatChildren => true;
|
public override bool ConsumesDatChildren => true;
|
||||||
|
|
||||||
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
|
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
|
||||||
public uint ItemId { get; private set; }
|
public uint ItemId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves <see cref="ItemId"/> to its retail tooltip text (a
|
||||||
|
/// <see cref="AcDream.Core.Items.ClientObject.GetTooltipDisplayName"/>
|
||||||
|
/// call bound by the owning controller — every construction site already
|
||||||
|
/// has a <c>ClientObjectTable</c> reference in scope, matching how
|
||||||
|
/// <see cref="SpriteResolve"/> is wired). Null/empty result shows no
|
||||||
|
/// tooltip, matching retail's <c>UIItem_Update</c> early-out
|
||||||
|
/// (<c>weenObj == 0</c> -> <c>UIElement::ClearTooltip</c>) for an
|
||||||
|
/// empty or not-yet-materialized cell.
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint, string?>? TooltipTextResolve { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port of <c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c>: 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 <see cref="UiButton.GetTooltipText"/> and
|
||||||
|
/// <see cref="UiRadar.GetTooltipText"/> — rather than cached at
|
||||||
|
/// <see cref="SetItem"/> time, since nothing observes a stale value between
|
||||||
|
/// item-state changes and the next hover dwell.
|
||||||
|
/// </summary>
|
||||||
|
public override string? GetTooltipText()
|
||||||
|
=> ItemId != 0 ? TooltipTextResolve?.Invoke(ItemId) : null;
|
||||||
|
|
||||||
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
|
||||||
public uint IconTexture { get; private set; }
|
public uint IconTexture { get; private set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,22 @@ public sealed class ClientObject
|
||||||
if (string.IsNullOrEmpty(Name)) return Name;
|
if (string.IsNullOrEmpty(Name)) return Name;
|
||||||
return Name[^1] == 's' ? Name + "es" : Name + "s";
|
return Name[^1] == 's' ? Name + "es" : Name + "s";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports <c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c> — the item-cell
|
||||||
|
/// hover-tooltip text every physical item/container/shortcut cell shows.
|
||||||
|
/// Retail resolves the name with <c>NAME_APPROPRIATE</c> (the same call
|
||||||
|
/// <see cref="GetAppropriateName"/> already ports) and, only when the stack
|
||||||
|
/// holds more than one (<c>_stackSize_1 > 1 @0x004e1d12</c>), prefixes the
|
||||||
|
/// count via <c>PStringBase<unsigned short>::sprintf(&__return,
|
||||||
|
/// u"%d %s")</c> — count first, one space, then the (already
|
||||||
|
/// singular-or-plural) name.
|
||||||
|
/// </summary>
|
||||||
|
public string GetTooltipDisplayName()
|
||||||
|
{
|
||||||
|
string name = GetAppropriateName();
|
||||||
|
return StackSize > 1 ? $"{StackSize} {name}" : name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,51 @@ public sealed class CursorFeedbackControllerTests
|
||||||
Assert.False(feedback.Cursor.IsValid);
|
Assert.False(feedback.Cursor.IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[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]
|
[Fact]
|
||||||
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
|
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -589,4 +589,148 @@ public sealed class RetailTooltipPresenterTests
|
||||||
if (FindById(child, datElementId) is { } found) return found;
|
if (FindById(child, datElementId) is { } found) return found;
|
||||||
return null;
|
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<UiText>(textChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,125 @@ public sealed class TooltipLiveDatTests
|
||||||
private const uint OptionsToggleRowTemplateId = 0x10000218u;
|
private const uint OptionsToggleRowTemplateId = 0x10000218u;
|
||||||
private const uint OptionsToggleCheckboxId = 0x10000219u;
|
private const uint OptionsToggleCheckboxId = 0x10000219u;
|
||||||
|
|
||||||
|
/// <summary>Retail's <c>UIElement_UIItem</c> registered class id
|
||||||
|
/// (<c>UIElement_UIItem::Register @0x0047A488</c>:
|
||||||
|
/// <c>RegisterElementClass(0x10000032, ...)</c>). 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.</summary>
|
||||||
|
private const uint UiItemElementType = 0x10000032u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item-tooltip investigation (docs/ISSUES.md #409 follow-on): pins the
|
||||||
|
/// finding that justifies <c>UiItemSlot</c> hardcoding a single popup-
|
||||||
|
/// locator pair rather than reading it per prototype. Every standalone
|
||||||
|
/// UIItem prototype (type <c>0x10000032</c>) in the shared cell-template
|
||||||
|
/// catalog (<see cref="ItemListCellTemplate.CatalogLayoutId"/>, LayoutDesc
|
||||||
|
/// <c>0x21000037</c>) resolves the SAME <c>P0x47/P0x48</c> pair through
|
||||||
|
/// catalog inheritance — <c>0x10000395</c> within <c>0x21000041</c>, one of
|
||||||
|
/// the four popup skins <see cref="TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild"/>
|
||||||
|
/// already pins. None author literal <c>P0x49</c> text or the <c>P0x4B</c>
|
||||||
|
/// on-bit — matching retail's runtime-text game-code sites
|
||||||
|
/// (<c>UIElement_UIItem::UpdateTooltip @0x004E1CB0</c> sets both the text
|
||||||
|
/// and the on-bit itself; see <see cref="RetailTooltipPresenter.OnTooltipShow"/>'s
|
||||||
|
/// <c>fromRuntime</c> bypass).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// World-object tooltip investigation (docs/ISSUES.md #409 follow-on).
|
||||||
|
/// <c>UIElement_SmartBoxWrapper</c> (retail's registered class
|
||||||
|
/// <c>0x10000030</c>, <c>UIElement_SmartBoxWrapper::Register @0x0047A47E</c>)
|
||||||
|
/// is the caller of the world-hover tooltip's own
|
||||||
|
/// <c>UIElement::SetTooltip</c>/<c>StartTooltipAtMouse</c> pair
|
||||||
|
/// (<c>RecvNotice_SmartBoxObjectFound @0x004E5AD0</c>, calls at
|
||||||
|
/// <c>@0x004E5D74</c>/<c>@0x004E5DFB</c>) — but this exhaustive sweep of
|
||||||
|
/// every installed <c>LayoutDesc</c> 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
|
||||||
|
/// (<c>gmGamePlayUI</c>'s own mode setup) rather than from a walkable
|
||||||
|
/// authored <c>ElementDesc</c>, so its own <c>P0x47</c>/<c>P0x48</c>
|
||||||
|
/// cannot be read from the DAT the way every other tooltip trigger's
|
||||||
|
/// can. <see cref="RetailTooltipPresenter"/>'s world-hover popup therefore
|
||||||
|
/// REUSES the item-catalog's confirmed uniform pair
|
||||||
|
/// (<c>P0x47=0x10000395</c>/<c>P0x48=0x21000041</c>) — the SAME "generic
|
||||||
|
/// runtime-text" skin every other game-code <c>SetTooltip</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[InstalledDatFact]
|
||||||
|
public void SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled()
|
||||||
|
{
|
||||||
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||||
|
|
||||||
|
int found = 0;
|
||||||
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retail's <c>UIElement_SmartBoxWrapper</c> registered class id.</summary>
|
||||||
|
private const uint SmartBoxWrapperElementType = 0x10000030u;
|
||||||
|
|
||||||
|
/// <summary>The item-cell popup-locator P0x47, hardcoded onto every
|
||||||
|
/// <see cref="UiItemSlot"/> — see that class's own doc comment.</summary>
|
||||||
|
private const uint UiItemTooltipRootElementId = 0x10000395u;
|
||||||
|
|
||||||
private static IEnumerable<UiElement> AllWidgets(UiElement root)
|
private static IEnumerable<UiElement> AllWidgets(UiElement root)
|
||||||
{
|
{
|
||||||
yield return root;
|
yield return root;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue