acdream/src/AcDream.App/UI/Layout/LayoutImporter.cs
Erik 5f9ca18155 fix(ui): #409 live-failure round — tooltips read the RUNTIME text first
User gate on 1.0.3-tt.a: tooltips appeared NOWHERE in-world except one on
the paperdoll. Root-caused, fixed, and live-verified against a connected
client the same day. Two findings, both measured; neither is a broken
hover/hit-test.

1. DOMINANT ROOT CAUSE — RetailTooltipPresenter.OnTooltipShow gated on
   widget.AuthoredTooltipText (P0x49) alone. Retail's
   UIElement::StartTooltipAtMouse @0x00460D70 takes the RUNTIME m_TTText
   first (@0x00460DA3 IsValid -> @0x00460DAA verbatim) and only falls back
   to InqProperty(0x49) at @0x00460DDF. acdream ALREADY had the runtime
   layer — UiElement.GetTooltipText(), written by the Options/Chat/Config
   page controllers, KeyboardConfigController, the social pages and
   UiCheckboxBitfield64 — but nothing read it.

   Live-DAT measured: the Options toggle-row checkbox (0x2100002B template
   root 0x10000218, leaf 0x10000219) authors P0x47=0x10000397
   P0x48=0x21000041 P0x4B=true and an EMPTY P0x49 — the popup locator and
   the on-bit are authored; only the text arrives at runtime, exactly as
   UIOption_CheckboxBitfield64::CreateChildren @0x00485E65 stamps its
   siTooltip array. Re-measured client-wide: ALL 187 no-literal-text
   tooltip elements author both locator ids, i.e. the whole set is
   runtime-text targets.

   Fixed by ResolveTooltipText (retail's order), plus:
   - the P0x4B gate now applies only to the AUTHORED-text path, because
     retail's eight game-code SetTooltip sites set the on-bit themselves
     (__bitfield164 |= 0x20 at @0x004E1D5E/@0x004A52F4/@0x004C63AC/
     @0x004C67ED/@0x004C7000/@0x004C7218/@0x004D9617/@0x00467076);
   - the P0x48-absent fallback to the element's own LayoutDesc
     (@0x00460E7E, this->m_layout->m_DID) is ported via the new
     UiElement.SourceLayoutDid, threaded from LayoutImporter.Build's new
     sourceLayoutDid parameter and passed by Import + the four template
     resolvers.

2. THE "243 SHOWABLE" NUMBER WAS NEVER AN IN-WORLD NUMBER. Grouped
   re-sweep: all 243 sit in CHARACTER-CREATION layouts. The inventory
   window (0x21000023) and paperdoll (0x21000024) author exactly two
   between them — 0x100001D6 "Drag clothing and armor here to wear them"
   (the doll drag mask) and 0x100005BE (the Slots button). The first IS
   the user's single working tooltip, so the paperdoll was never a
   differential against a broken mechanism. Reachability was measured and
   is fine: 238/243 build as real non-ClickThrough hover targets.

LIVE VERIFICATION (connected testaccount/+Acdream, Release,
ACDREAM_RETAIL_UI=1): Options -> Character -> "Vivid Targeting Indicator"
now shows its full ID_PlayerOption_*_Help sentence; a temporary hover probe
confirmed the hover target is element 0x10000219 with runtime=True. The
paperdoll tooltip still shows. An inventory ITEM still shows nothing —
that is UIElement_UIItem::UpdateTooltip @0x004E1CB0 (retail shows the item
name, "%d %s"-prefixed when the stack is > 1), which stays deferred:
UiItemSlot is constructed programmatically at 6+ sites and carries neither
the P0x47 locator nor a name source, so it is its own slice.

Bookkeeping: register TS-85 narrowed (m_TTText READ side now ported; the
row now enumerates all 15 SetTooltip call sites split into ported vs
no-acdream-analog). #409's gate note rewritten to lead with the in-world
surfaces — the old note listed only chargen, which is why it could not
have caught this. Filed #411 for the hover-cursor scope addition: an
exhaustive raw scan of every ElementDesc found only 101 authored
MediaDescCursor entries, all on Dragbar/Resizebar with the 5 DIDs
RetailCursorCatalog already hardcodes, so retail has NO per-element cursor
for inventory items; the likely mechanism is the rollover STATE
(UIElement::MouseOverTop @0x004615D0) that UiItemSlot lacks entirely.

Gates: Release build 0 errors; App suite (live-DAT env) 5424/5421 passed/3
skips (was 5416/5413/3, +8 new tests); Runtime 1735/0; full solution (no
env) 14,631/14,561 passed/70 skipped/0 failed (was 14,623/14,554/69).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:44:44 +02:00

854 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
/// an O(1) lookup table for finding any element by its dat id.
/// </summary>
public sealed class ImportedLayout
{
/// <summary>Root widget of the imported tree.</summary>
public UiElement Root { get; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
{
Root = root;
_byId = byId;
}
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
public UiElement? FindElement(uint id)
=> _byId.TryGetValue(id, out var e) ? e : null;
}
/// <summary>
/// Two-layer layout importer for retail LayoutDesc dat objects.
///
/// <para>
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
/// </para>
///
/// <para>
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
/// </para>
///
/// <para>
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
/// Every other element type recurses its children generically.
/// </para>
/// </summary>
public static class LayoutImporter
{
// ── Pure layer ────────────────────────────────────────────────────────────
/// <summary>
/// Convenience for tests: attach <paramref name="children"/> to
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
/// The children list is set directly on <paramref name="rootInfo"/>;
/// any existing children are replaced.
/// </summary>
public static ImportedLayout BuildFromInfos(
ElementInfo rootInfo,
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont, fontResolve, stringResolve);
}
/// <summary>
/// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached).
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver — FontDid →
/// <see cref="UiDatFont"/> (or null if the font can't be loaded). When supplied,
/// elements with a non-zero <see cref="ElementInfo.FontDid"/> get their own dat
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
/// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param>
/// <param name="sourceLayoutDid">#409: the LayoutDesc DID these infos came from,
/// recorded on every built widget as <see cref="UiElement.SourceLayoutDid"/>.
/// Retail keeps the same back-pointer (<c>UIElement::m_layout</c>) and reads it in
/// <c>StartTooltipAtMouse @0x00460E7E</c> as the tooltip popup layout when the
/// element authors no <c>P0x48</c>. Zero (the default) leaves it unknown, which
/// simply declines that fallback — callers with no dat context pass nothing.</param>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null,
uint sourceLayoutDid = 0u)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
root = new UiDatElement(rootInfo, resolve);
}
return new ImportedLayout(root, byId);
}
private static UiElement? BuildWidget(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve,
Dictionary<uint, UiElement> byId,
uint sourceLayoutDid)
{
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip
// #409: see the Build overload's own sourceLayoutDid doc comment.
w.SourceLayoutDid = sourceLayoutDid;
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
w.AuthoredInvisible = info.Invisible;
// #409: the six per-element tooltip properties, same pure-data-
// passthrough shape as AuthoredInvisible above. TooltipText is the
// one property resolved to a display string here (mirrors every
// other authored StringInfo caption in this file); the rest stay
// raw ids/flags for RetailTooltipPresenter to act on.
w.AuthoredTooltipEnabled = info.TooltipEnabled;
w.AuthoredTooltipText = DatWidgetFactory.ResolveTooltipText(info, stringResolve);
w.AuthoredTooltipRootElementId = info.TooltipRootElementId;
w.AuthoredTooltipLayoutDid = info.TooltipLayoutDid;
w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId;
w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds;
// #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F).
w.AuthoredResizeMaxWidth = info.MaxWidth;
w.AuthoredResizeMinWidth = info.MinWidth;
w.AuthoredResizeMaxHeight = info.MaxHeight;
w.AuthoredResizeMinHeight = info.MinHeight;
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat
// sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
// Button labels, Scrollbar arrows) CONSUME their dat children — building those as
// separate widgets double-draws and lets an invisible child steal pointer/focus
// from the behavioral widget (e.g. the channel Menu's label child intercepting the
// button click). Only generic containers (UiDatElement, panels) recurse. See
// UiElement.ConsumesDatChildren.
if (!w.ConsumesDatChildren)
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is not null) w.AddChild(cw);
}
}
else if (w is UiMeter)
{
// Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text
// label/value overlays that are separate renderable widgets (not part of the bar
// art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers
// (reads their grandchild sprite ids to populate Back*/Front* properties). The
// remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such
// as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong
// in the widget tree. We build them here explicitly, registered in byId so
// FindElement can locate them, and attached as children of the meter so they render
// as overlays at their dat-local coordinates. The controller can then locate these
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
//
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they
// carry the 3-slice sprite ids, not text content; building them again would
// double-draw the bar art). All other child types are built normally.
//
// Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children
// (no text children). This loop finds nothing for them → no change to vitals.
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is not null) w.AddChild(cw);
}
}
else if (w is UiText or UiField)
{
// Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's
// coarse ConsumesDatChildren=true (UiText outside its
// PassToChildren carve-out; UiField unconditionally) used to
// drop EVERY dat child, including ones that carry their own
// renderable media — retail's UIElement_Text/Field genuinely
// composites those as real chrome/controls, not swallowed
// caption/face art the way a Button's or Meter's children are.
// Live-DAT-measured (chargen's three shared description boxes,
// 0x100003e0/0x10000409/0x10000404): the eight gold-frame
// pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState
// sprite each) and the linked scrollbar (0x100002E7, Type 11,
// its own DirectState track sprite plus three Button
// sub-children BuildScrollbar resolves internally) all carry
// non-empty StateMedia on THEMSELVES. Purely structural/
// property-only children (StateMedia.Count == 0 — e.g. a
// lifted-caption-only child some OTHER element type might
// still want swallowed) stay dropped exactly as before; this
// is additive, not a relaxation of the PassToChildren gate
// itself.
foreach (var child in info.Children)
{
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId, sourceLayoutDid);
if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through
// THIS carve-out only — e.g. the chat new-text indicator
// (0x1000048C, live-DAT-confirmed Invisible=true on every
// layout it appears in) would otherwise render as a phantom
// element retail never shows, now that this carve-out
// builds it as a real widget instead of silently dropping
// it. This is NOT the general client-wide honor (#408,
// 1,083 elements) — every OTHER AuthoredInvisible consumer
// stays data-only, acted on nowhere but chargen's own
// HideAuthoredInvisibleElements walk (register AP-230).
if (cw.AuthoredInvisible)
cw.Visible = false;
w.AddChild(cw);
}
}
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
// child tree exists. Re-applying the imported default here gives retained
// PassToChildren tabs their authored Open/Closed child media without turning
// spatial construction into controller-specific special cases.
if (w is IUiDatStateful stateful)
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
// See IUiChildrenAttachedListener: a widget that must resolve OTHER children by
// dat element id (e.g. UiTabPanel's tab table) can only do so once its subtree
// is actually attached, which just happened above.
if (w is IUiChildrenAttachedListener childrenAttached)
childrenAttached.OnChildrenAttached();
return w;
}
// ── Dat shell ─────────────────────────────────────────────────────────────
/// <summary>
/// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the
/// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests.
/// Returns null if the layout is missing.
/// </summary>
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
public static ElementInfo? ImportInfos(IDatReaderWriter dats, uint layoutId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
// Collect the set of element ids that are referenced as a BaseElement by ANY
// element in THIS layout (where BaseLayoutId == layoutId). Such elements are
// purely inheritance templates ("prototypes") — retail never instantiates them
// as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc
// 0x21000016, which all 18 slot elements inherit from and which has no own media.
//
// NOTE: the Resolve path reads BaseElement from the raw dat directly (via
// dats.Get<LayoutDesc>), so the prototype never needs to appear in the built
// widget tree for inheritance to work. Skipping it here is safe.
var referencedAsBase = new HashSet<uint>();
foreach (var kv in ld.Elements)
CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase);
var tops = new List<ElementInfo>();
foreach (var kv in ld.Elements)
{
// Skip pure prototype elements: top-level elements that are referenced as a
// base template by another element in this same layout AND have no own state
// media (so they draw nothing and contribute nothing but their inherited shape).
var d = kv.Value;
if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d))
{
Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement).");
continue;
}
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
}
// #375: a Type-5 ListBox's row-template list (dat property 0x64) can
// name SAME-LAYOUT elements as its row prototypes — gmKeyboardUI
// (0x21000009) authors its header (0x1000002E) and action-row
// (0x1000002F, the three key buttons) templates as ordinary top-level
// siblings of the screen. Retail never instantiates a template-list
// element as a live widget (AddItemFromTemplateList clones from the
// desc on demand — the SAME re-import our UiTemplateListBox's
// TemplateResolver performs), so building them here parked two live
// prototype rows at the screen's (0,0), over and outside the framed
// panel. Same skip class as the BaseElement prototypes above, keyed on
// the template-list reference instead. Same-LAYOUT references only:
// element ids collide across layouts (0x10000211 is a page in BOTH the
// options and keyboard layouts), so a cross-layout entry must never
// suppress a coincidentally-same-id element here.
var referencedAsTemplate = new HashSet<uint>();
foreach (ElementInfo top in tops)
CollectTemplateRefs(top, layoutId, referencedAsTemplate);
if (referencedAsTemplate.Count > 0)
{
for (int i = tops.Count - 1; i >= 0; i--)
{
if (!referencedAsTemplate.Contains(tops[i].Id)) continue;
Console.WriteLine(
$"[D.2b] LayoutImporter: skipping row-template element 0x{tops[i].Id:X8} "
+ $"in layout 0x{layoutId:X8} (referenced by a same-layout template list).");
tops.RemoveAt(i);
}
}
if (tops.Count == 1)
return tops[0];
foreach (var top in tops)
SetOriginalParentSize(top, ld.Width, ld.Height);
return new ElementInfo
{
Id = 0,
Type = 3,
Width = ld.Width,
Height = ld.Height,
Children = tops,
};
}
/// <summary>Recursively gathers every SAME-LAYOUT element id referenced by a
/// row-template list (dat property 0x64) anywhere in <paramref name="info"/>'s
/// resolved subtree — the skip set for the #375 parked-prototype filter above.</summary>
private static void CollectTemplateRefs(
ElementInfo info, uint layoutId, HashSet<uint> referenced)
{
foreach (UiTemplateListEntry entry in info.TemplateList)
{
if (entry.TemplateLayoutId == layoutId)
referenced.Add(entry.TemplateElementId);
}
foreach (ElementInfo child in info.Children)
CollectTemplateRefs(child, layoutId, referenced);
}
/// <summary>
/// Retail <c>UIElementManager::CreateRootElementByDataID</c> counterpart: resolve one
/// authored root from a catalog-style LayoutDesc instead of instantiating every
/// top-level template. DialogFactory uses this path for the shared dialog catalog.
/// </summary>
public static ElementInfo? ImportInfos(
IDatReaderWriter dats,
uint layoutId,
uint rootElementId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
ElementDesc? root = FindDesc(ld, rootElementId);
return root is null
? null
: Resolve(dats, root, new HashSet<(uint, uint)>());
}
/// <summary>
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
/// element, and build the widget tree. Returns null if the layout is absent
/// from the dats.
///
/// <para>
/// <b>Dat UIState visibility model (2026-06-26 audit):</b>
/// The dat's <c>ElementDesc.DefaultState</c> field specifies which SPRITE/MEDIA
/// state an element starts in (e.g., <c>Normal</c>, <c>Minimized</c>). It does
/// NOT encode visibility of sibling Group containers. The <c>StateDesc</c>'s
/// <see cref="DatReaderWriter.Enums.IncorporationFlags"/> contains X/Y/Width/Height/
/// ZLevel/PassToChildren — there is no Visible flag.
/// </para>
///
/// <para>
/// Windows that display multiple sibling Group containers at the same position
/// (the character footer's three state-groups; the tab-page content areas) manage
/// visibility purely at runtime via C++ controller code. Retail uses
/// <c>UIElement::SetState(stateId)</c> on the parent to propagate state, then
/// C++ getters access the right sub-group by element id. All groups are shipped
/// as visible in the imported widget tree; the relevant controllers
/// (<see cref="CharacterStatController"/>) perform the initial show/hide.
/// </para>
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
public static ImportedLayout? Import(
IDatReaderWriter dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve, layoutId);
}
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
public static ImportedLayout? Import(
IDatReaderWriter dats,
uint layoutId,
uint rootElementId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var rootInfo = ImportInfos(dats, layoutId, rootElementId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve, layoutId);
}
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
/// correction. Child inheritance itself is unconditional and follows retail
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
/// <summary>
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
/// </summary>
private static ElementInfo Resolve(
IDatReaderWriter dats,
ElementDesc d,
HashSet<(uint layoutId, uint elementId)> baseChain)
{
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
ElementInfo? baseInfo = null;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; children are attached below.
result = ElementReader.Merge(baseInfo, self);
}
}
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
// base-only children remain, same-ID children incorporate recursively, and
// derived-only children append after the retained base entries.
IncorporateChildren(dats, result, baseInfo?.Children, d);
// A pure-container sub-window mount needs one additional layer correction.
// Child-table incorporation has already happened above for every inheritor.
if (baseInfo is not null
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
{
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
// ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder ZLevel·10000)
// turns 1000 into ZOrder ≈ 10,000,000 — sinking the whole panel BEHIND the frame's
// Alphablend backdrop (ZLevel 100 → ≈ 1,000,000). The backdrop then overpaints the
// panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
// Mounted descendants retain the base layout's design parent size. The runtime
// UiLayoutPolicy therefore performs the retail parent-size update from the correct
// baseline when this slot is larger than the mounted layout.
}
return result;
}
private static void IncorporateChildren(
IDatReaderWriter dats,
ElementInfo result,
IReadOnlyList<ElementInfo>? baseChildren,
ElementDesc derived)
{
baseChildren ??= Array.Empty<ElementInfo>();
var baseById = baseChildren.ToDictionary(child => child.Id);
int retainedBaseCount = baseChildren.Count(child =>
!derived.Children.ContainsKey(child.Id));
foreach (ElementInfo baseChild in baseChildren)
{
bool hasOverlay = derived.Children.TryGetValue(
baseChild.Id, out ElementDesc? overlay);
ElementInfo child = hasOverlay
? IncorporateResolvedChild(dats, baseChild, overlay!)
: baseChild;
// Base-only descendants retain the base layout's design parent size;
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
if (hasOverlay)
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Every new child receives a fresh base-chain set. Retail offsets its read order
// by the count of inherited children not replaced by a same-ID overlay.
foreach (var pair in derived.Children)
{
if (baseById.ContainsKey(pair.Key)) continue;
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
child.ReadOrder += checked((uint)retainedBaseCount);
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
}
private static ElementInfo IncorporateResolvedChild(
IDatReaderWriter dats,
ElementInfo baseChild,
ElementDesc derivedChild)
{
// ElementDesc::Incorporate consumes the partial child directly. It does not
// independently re-resolve that child's BaseElement when the inherited table
// already contains the same identity.
ElementInfo self = ToInfo(derivedChild);
ElementInfo result = ElementReader.Merge(baseChild, self);
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
/// attached (the caller handles those).
/// </summary>
private static ElementInfo ToInfo(ElementDesc d)
{
// Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
// no default is set; map those to "" so UiDatElement treats them as "no preference".
var defState = d.DefaultState.ToString();
var info = new ElementInfo
{
Id = d.ElementId,
Type = d.Type,
X = (float)d.X,
Y = (float)d.Y,
Width = (float)d.Width,
Height = (float)d.Height,
Left = d.LeftEdge,
Top = d.TopEdge,
Right = d.RightEdge,
Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder,
ZLevel = d.ZLevel,
DefaultStateId = (uint)d.DefaultState,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// DirectState (unnamed, key "").
if (d.StateDesc is not null)
ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info);
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
foreach (var s in d.States)
ReadState(s.Value, (uint)s.Key, s.Key.ToString(), info);
ElementReader.ApplyCanonicalLegacyProjection(info);
return info;
}
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c>, read any <see cref="MediaDescCursor"/> into
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, uint stateId, string name, ElementInfo info)
{
var state = new UiStateInfo
{
Id = stateId,
Name = name,
PassToChildren = sd.PassToChildren,
IncorporationFlags = (uint)sd.IncorporationFlags,
};
bool imageRead = false;
foreach (var m in sd.Media)
{
if (!imageRead && m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
imageRead = true;
}
if (m is MediaDescCursor cursor && cursor.File != 0)
{
info.StateCursors[name] = new UiCursorMedia(
cursor.File,
checked((int)cursor.XHotspot),
checked((int)cursor.YHotspot));
state.Cursor = info.StateCursors[name];
}
}
if (sd.Properties is not null)
{
foreach (var (propertyId, property) in sd.Properties)
state.Properties.Values[propertyId] = ConvertProperty(property);
}
info.States[stateId] = state;
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
if (info.FontDid == 0 && sd.Properties is not null
&& sd.Properties.TryGetValue(0x1Au, out var raw)
&& raw is ArrayBaseProperty arr && arr.Value.Count > 0
&& arr.Value[0] is DataIdBaseProperty did)
{
info.FontDid = did.Value;
}
if (sd.Properties is not null)
{
// HorizontalJustification (0x14): EnumBaseProperty.
// Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right,
// every other value (including constructor default 2)=Left.
// Only update if still at the default (Center); derived-wins handled in Merge.
if (info.HJustify == HJustify.Center
&& sd.Properties.TryGetValue(0x14u, out var hRaw)
&& hRaw is EnumBaseProperty hEnum)
{
info.HJustify = hEnum.Value switch
{
0u or 2u => HJustify.Left,
1u => HJustify.Center,
3u => HJustify.Right,
5u => HJustify.Right,
_ => HJustify.Left,
};
}
// VerticalJustification (0x15): EnumBaseProperty.
// Retail values: 2=Top, 4=Bottom; absent/other = Center.
if (info.VJustify == VJustify.Center
&& sd.Properties.TryGetValue(0x15u, out var vRaw)
&& vRaw is EnumBaseProperty vEnum)
{
info.VJustify = vEnum.Value switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
// ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A).
// Only read when not already set (first dat state wins; Merge propagates from base).
if (info.FontColor is null
&& sd.Properties.TryGetValue(0x1Bu, out var cRaw)
&& cRaw is ColorBaseProperty cProp)
{
var c = cProp.Value;
// ColorARGB stores components as bytes (0255); normalize to [0,1] for Vector4.
// Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255).
float a = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a);
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField &
// 0x10. NOT read here — reading it per-state as each StateDesc is visited is an
// any-state "first wins" scan that can pick up a NON-effective state's property
// (e.g. a Pressed-only override) ahead of the state retail would actually use.
// ElementReader.ApplyCanonicalLegacyProjection (called once per element, right
// after every state is read) is the single correct source: it resolves 0x21
// through TryGetEffectiveProperty's DirectState-then-effective-default-state rule,
// exactly like FontDid/HJustify/VJustify/FontColor already do. Round-5 review N1:
// this duplicate early read was masked while Outline only reached UiText; S2's
// widening to six more text-bearing widgets un-masks a state mismatch here.
// OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default
// RGBAColor_Black. Only read when not already set — same pattern as FontColor.
if (info.OutlineColor is null
&& sd.Properties.TryGetValue(0x22u, out var outlineColorRaw)
&& outlineColorRaw is ColorBaseProperty outlineColorProp)
{
var oc = outlineColorProp.Value;
float oa = oc.Alpha == 0 ? 1f : oc.Alpha / 255f;
info.OutlineColor = new System.Numerics.Vector4(oc.Red / 255f, oc.Green / 255f, oc.Blue / 255f, oa);
}
}
}
internal static UiPropertyValue ConvertProperty(BaseProperty property)
{
var value = new UiPropertyValue { MasterPropertyId = property.MasterPropertyId };
switch (property)
{
case EnumBaseProperty p:
value.Kind = UiPropertyKind.Enum;
value.UnsignedValue = p.Value;
break;
case BoolBaseProperty p:
value.Kind = UiPropertyKind.Bool;
value.BoolValue = p.Value;
break;
case DataIdBaseProperty p:
value.Kind = UiPropertyKind.DataId;
value.UnsignedValue = p.Value;
break;
case FloatBaseProperty p:
value.Kind = UiPropertyKind.Float;
value.FloatValue = p.Value;
break;
case IntegerBaseProperty p:
value.Kind = UiPropertyKind.Integer;
value.IntegerValue = p.Value;
break;
case StringInfoBaseProperty p:
value.Kind = UiPropertyKind.StringInfo;
value.StringInfoValue = new UiStringInfoValue(
p.Value.Token,
p.Value.StringId,
p.Value.TableId.DataId,
(byte)p.Value.Override,
p.Value.English,
p.Value.Comment);
break;
case ColorBaseProperty p:
value.Kind = UiPropertyKind.Color;
value.ColorValue = new UiColorValue(
p.Value.Blue,
p.Value.Green,
p.Value.Red,
p.Value.Alpha);
break;
case ArrayBaseProperty p:
value.Kind = UiPropertyKind.Array;
foreach (var item in p.Value)
value.ArrayValue.Add(ConvertProperty(item));
break;
case StructBaseProperty p:
value.Kind = UiPropertyKind.Struct;
foreach (var (key, item) in p.Value)
value.StructValue[key] = ConvertProperty(item);
break;
case VectorBaseProperty p:
value.Kind = UiPropertyKind.Vector;
value.VectorValue = p.Value;
break;
case Bitfield32BaseProperty p:
value.Kind = UiPropertyKind.Bitfield32;
value.UnsignedValue = p.Value;
break;
case Bitfield64BaseProperty p:
value.Kind = UiPropertyKind.Bitfield64;
value.UnsignedValue = p.Value;
break;
case InstanceIdBaseProperty p:
value.Kind = UiPropertyKind.InstanceId;
value.UnsignedValue = p.Value;
break;
default:
throw new NotSupportedException($"Unsupported UI base-property type {property.GetType().FullName}.");
}
return value;
}
// ── Prototype detection helpers ───────────────────────────────────────────
/// <summary>
/// Recursively walks <paramref name="d"/> and all its children, adding to
/// <paramref name="result"/> the <c>BaseElement</c> of every descriptor that
/// references this layout (<c>BaseLayoutId == layoutId</c>). Used by
/// <see cref="ImportInfos"/> to identify pure prototype/template elements that
/// should not be instantiated as live widgets.
/// </summary>
private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet<uint> result)
{
if (d.BaseElement != 0 && d.BaseLayoutId == layoutId)
result.Add(d.BaseElement);
foreach (var kv in d.Children)
CollectBaseRefsInDesc(kv.Value, layoutId, result);
}
/// <summary>
/// Returns true when <paramref name="d"/> carries no own state media — i.e. its
/// <c>StateDesc</c> (DirectState) and <c>States</c> (named states) yield no
/// <see cref="MediaDescImage"/> entries with a non-zero file id.
/// Such elements are pure inheritance templates with no rendering content.
/// </summary>
private static bool HasNoOwnMedia(ElementDesc d)
{
// Re-use ToInfo's media extraction: if the resulting StateMedia is empty the
// element has no renderable image in any state.
var info = ToInfo(d);
return info.StateMedia.Count == 0;
}
// ── Element tree search ───────────────────────────────────────────────────
/// <summary>
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
/// <paramref name="ld"/> (depth-first). Returns null if not found.
/// </summary>
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
// ── Raw-edge layout provenance ────────────────────────────────────────────
private static void SetOriginalParentSize(ElementInfo child, float width, float height)
{
child.OriginalParentWidth = width;
child.OriginalParentHeight = height;
child.HasOriginalParentSize = true;
}
}