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 <noreply@anthropic.com>
354 lines
18 KiB
C#
354 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Content;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Options;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// #409 (client-wide retail tooltip system) installed-DAT acceptance gate.
|
|
/// Opt in with <c>ACDREAM_PROBE_LIVE_MOUNT=1</c>; <c>ACDREAM_DAT_DIR</c> can
|
|
/// override the ordinary Documents/Asheron's Call location. Pins retail's
|
|
/// tooltip popup LayoutDesc <c>0x21000041</c> structure and a client-wide
|
|
/// sweep landmark, mirroring <see cref="CharacterManagementLiveDatTests"/>'s
|
|
/// pattern.
|
|
/// </summary>
|
|
public sealed class TooltipLiveDatTests
|
|
{
|
|
private static string DatDirectory =>
|
|
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
|
?? Path.Combine(
|
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
|
"Documents",
|
|
"Asheron's Call");
|
|
|
|
/// <summary>The tooltip popup catalog LayoutDesc — every probed
|
|
/// tooltip-bearing element's P0x48 resolves here (with one exception,
|
|
/// 0x21000026, an alternate skin used by a handful of elements —
|
|
/// RetailTooltipPresenter is data-driven off each element's OWN P0x48
|
|
/// so neither this constant nor 0x21000026 is hardcoded in production
|
|
/// code; it is cited here only to pin the probe's own finding).</summary>
|
|
private const uint TooltipCatalogLayoutId = 0x21000041u;
|
|
|
|
/// <summary>The four popup "skin" root element ids live-DAT-probed inside
|
|
/// <see cref="TooltipCatalogLayoutId"/>. Each is a 30x30 four-piece bevel
|
|
/// frame around the SAME text child id (<see cref="TooltipTextChildId"/>).</summary>
|
|
private static readonly uint[] PopupSkinRootIds =
|
|
[0x10000487u, 0x10000395u, 0x10000397u, 0x10000398u];
|
|
|
|
private const uint TooltipTextChildId = 0x10000396u;
|
|
|
|
[InstalledDatFact]
|
|
public void TooltipCatalog_EveryPopupSkin_SharesTheSameTextChild()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
ElementInfo? root = LayoutImporter.ImportInfos(dats, TooltipCatalogLayoutId);
|
|
Assert.NotNull(root);
|
|
Assert.Equal(0u, root!.Id);
|
|
Assert.Equal(800f, root.Width);
|
|
Assert.Equal(600f, root.Height);
|
|
|
|
foreach (uint skinRootId in PopupSkinRootIds)
|
|
{
|
|
ElementInfo skin = Assert.Single(root.Children, c => c.Id == skinRootId);
|
|
// UIElementManager::StartTooltip @0x0045DE90's own fallback read:
|
|
// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) off the
|
|
// freshly-instantiated POPUP root itself.
|
|
Assert.Equal(TooltipTextChildId, skin.TooltipTextChildElementId);
|
|
|
|
ElementInfo textChild = Assert.Single(
|
|
AllDescendants(skin), e => e.Id == TooltipTextChildId);
|
|
Assert.Equal(12u, textChild.Type); // UIElement_Text
|
|
}
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void TooltipCatalog_ImportsThroughLayoutImporter_WithTextChildResolvable()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
ImportedLayout? popup = LayoutImporter.Import(
|
|
dats,
|
|
TooltipCatalogLayoutId,
|
|
0x10000487u,
|
|
_ => (0u, 0, 0),
|
|
null);
|
|
Assert.NotNull(popup);
|
|
Assert.Equal(30f, popup!.Root.Width);
|
|
Assert.Equal(30f, popup.Root.Height);
|
|
Assert.Equal(TooltipTextChildId, popup.Root.AuthoredTooltipTextChildElementId);
|
|
|
|
UiElement? textChild = popup.FindElement(TooltipTextChildId);
|
|
Assert.IsType<UiText>(textChild);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A known tooltip-bearing element (the Appearance page's left rotate
|
|
/// button, live-DAT-probed) authors all five trigger properties with
|
|
/// literal StringInfo text — the "core" case
|
|
/// <see cref="RetailTooltipPresenter"/> can show end to end.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void KnownElement_AuthorsAllFiveTooltipProperties_WithResolvableText()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x21000005u);
|
|
Assert.NotNull(tree);
|
|
ElementInfo rotateLeft = Assert.Single(AllDescendants(tree!), e => e.Id == 0x100005A4u);
|
|
|
|
Assert.True(rotateLeft.TooltipEnabled);
|
|
Assert.Equal(0x10000487u, rotateLeft.TooltipRootElementId);
|
|
Assert.Equal(TooltipCatalogLayoutId, rotateLeft.TooltipLayoutDid);
|
|
Assert.NotNull(rotateLeft.TooltipText);
|
|
|
|
var strings = new DatStringResolver(dats);
|
|
string? resolved = DatWidgetFactory.ResolveTooltipText(rotateLeft, strings.Resolve);
|
|
Assert.Equal("Rotate left.", resolved);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Landmark client-wide sweep (mirrors
|
|
/// <see cref="LayoutImporterMediaBearingChildSweepTests"/>'s own shape):
|
|
/// every installed LayoutDesc, counting elements authoring at least one
|
|
/// of the five tooltip-trigger properties. Asserts landmarks + a floor,
|
|
/// not a brittle exact total, so the gate survives a future DAT
|
|
/// revision.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void ClientWideSweep_FindsKnownLandmarksAndAFloorCount()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
var withProperties = new List<(uint LayoutId, uint ElementId, bool HasText, bool Showable)>();
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>())
|
|
{
|
|
ElementInfo? tree;
|
|
try { tree = LayoutImporter.ImportInfos(dats, layoutId); }
|
|
catch { continue; }
|
|
if (tree is null) continue;
|
|
|
|
foreach (ElementInfo e in AllDescendants(tree))
|
|
{
|
|
bool any = e.TooltipRootElementId != 0 || e.TooltipLayoutDid != 0
|
|
|| e.TooltipText.HasValue || e.TooltipEnabled
|
|
|| e.TooltipDelaySeconds.HasValue;
|
|
if (any)
|
|
{
|
|
// F9 (2026-08-16 review round): "with literal text" alone
|
|
// over-counts what RetailTooltipPresenter.OnTooltipShow
|
|
// actually shows — that gate is a FULL AND across
|
|
// TooltipEnabled (P0x4B) AND non-null text (P0x49) AND
|
|
// both popup-locator ids (P0x47/P0x48), not just text
|
|
// presence. Measure the real intersection instead of
|
|
// assuming "243 with text" == "243 showable".
|
|
bool showable = e.TooltipEnabled && e.TooltipText.HasValue
|
|
&& e.TooltipLayoutDid != 0 && e.TooltipRootElementId != 0;
|
|
withProperties.Add((layoutId, e.Id, e.TooltipText.HasValue, showable));
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"[409-DAT] {withProperties.Count} elements author >=1 tooltip property "
|
|
+ $"({withProperties.Count(f => f.HasText)} with literal StringInfo text, "
|
|
+ $"{withProperties.Count(f => f.Showable)} pass the full OnTooltipShow gate).");
|
|
|
|
// #409 investigation landmark (main game UI, Appearance rotate button).
|
|
Assert.Contains(withProperties, f => f.LayoutId == 0x21000005u && f.ElementId == 0x100005A4u);
|
|
// Floor: the live-DAT sweep found 430 total / 243 with literal text /
|
|
// 243 fully showable (2026-08-16, both post-F9 measurements) —
|
|
// assert comfortably below all three so a future content patch that
|
|
// only ADDS tooltip authoring cannot flake this gate.
|
|
Assert.True(withProperties.Count >= 400,
|
|
$"expected at least 400 tooltip-property-authoring elements, found {withProperties.Count}.");
|
|
Assert.True(withProperties.Count(f => f.HasText) >= 200,
|
|
$"expected at least 200 elements with literal tooltip text, found {withProperties.Count(f => f.HasText)}.");
|
|
Assert.True(withProperties.Count(f => f.Showable) >= 200,
|
|
$"expected at least 200 fully showable elements, found {withProperties.Count(f => f.Showable)}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// #409 live-failure round, the pin that protects the runtime-text fix.
|
|
/// The Options panel's authored toggle-row TEMPLATE (LayoutDesc
|
|
/// <c>0x2100002B</c>, row root <c>0x10000218</c>, checkbox leaf
|
|
/// <c>0x10000219</c>) carries the full tooltip POPUP LOCATOR
|
|
/// (<c>P0x47</c>/<c>P0x48</c>) and the <c>P0x4B</c> on-bit, but authors NO
|
|
/// <c>P0x49</c> text — its help string arrives at runtime, exactly as
|
|
/// retail's <c>UIOption_CheckboxBitfield64::CreateChildren @0x00485E65</c>
|
|
/// stamps its <c>siTooltip</c> array. This is why reading only
|
|
/// <c>AuthoredTooltipText</c> showed nothing anywhere in-world; see
|
|
/// <c>RetailTooltipPresenter.ResolveTooltipText</c>.
|
|
///
|
|
/// <para>The row template is template-list referenced, so
|
|
/// <see cref="LayoutImporter.ImportInfos"/>'s client-wide walk deliberately
|
|
/// filters it out (#375) — it has to be imported by its own root id.</para>
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void OptionsToggleRowTemplate_AuthorsThePopupLocatorButNoLiteralText()
|
|
{
|
|
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
|
|
|
ImportedLayout? row = LayoutImporter.Import(
|
|
dats, OptionsPanelLayoutId, OptionsToggleRowTemplateId, _ => (0u, 0, 0), null);
|
|
Assert.NotNull(row);
|
|
|
|
UiElement checkbox = Assert.Single(
|
|
AllWidgets(row!.Root), w => w.DatElementId == OptionsToggleCheckboxId);
|
|
|
|
Assert.Equal(0x10000397u, checkbox.AuthoredTooltipRootElementId); // P0x47
|
|
Assert.Equal(TooltipCatalogLayoutId, checkbox.AuthoredTooltipLayoutDid); // P0x48
|
|
Assert.True(checkbox.AuthoredTooltipEnabled); // P0x4B
|
|
Assert.True(string.IsNullOrEmpty(checkbox.AuthoredTooltipText)); // no P0x49
|
|
// ...and it is a real hover target, so UiRoot.UpdateHover can select it.
|
|
Assert.False(checkbox.ClickThrough);
|
|
// StartTooltipAtMouse @0x00460E7E's fallback source, stamped by the
|
|
// importer's dat shell.
|
|
Assert.Equal(OptionsPanelLayoutId, checkbox.SourceLayoutDid);
|
|
}
|
|
|
|
private const uint OptionsPanelLayoutId = 0x2100002Bu;
|
|
private const uint OptionsToggleRowTemplateId = 0x10000218u;
|
|
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)
|
|
{
|
|
yield return root;
|
|
foreach (UiElement child in root.Children)
|
|
foreach (UiElement descendant in AllWidgets(child))
|
|
yield return descendant;
|
|
}
|
|
|
|
private static IEnumerable<ElementInfo> AllDescendants(ElementInfo root)
|
|
{
|
|
yield return root;
|
|
foreach (ElementInfo child in root.Children)
|
|
foreach (ElementInfo descendant in AllDescendants(child))
|
|
yield return descendant;
|
|
}
|
|
}
|