The remaining code-bearing findings from the round review, F4-F16 minus the doc-only items (batched separately): - F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly the 4 Town buttons, confined to chargen; conflicting custom-selection- pair + standard Normal/Highlight media — zero found, no gate tightening needed; per-state label-color map — 209 matches beyond chargen, confirming AP-222's mechanism has always been broadly active since it shipped generically in DatWidgetFactory). - F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now honors a child's own AuthoredInvisible flag (a narrow honor scoped to exactly that carve-out, not the general #408 client-wide one) — the chat transcript's new-text indicator (0x1000048C) was building as a visible phantom element retail never shows; verified both directions against the gold-frame pieces, which do not author Invisible. - F7: BoundedProcessOutputCapture.AppendLine combines the line text and its trailing newline into one buffer and one file open/write/close instead of two. - F9: corrected a stale comment in RuntimeSettingsTargets — #407 split DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the fullscreen validator's own narrower list is now DELIBERATELY different from the Config dropdown's fuller offering, not the "must match" bug the comment described. - F10: documented (not changed) why the LabelBox path's default 3px inset and the face-relative +4px gap in DatWidgetFactory.BuildButton are deliberately different numbers — neither carries a retail citation, and moving either to match the other would be an unfounded guess on a button that currently works correctly. - F11: Heritage/Profession/Summary/Town description pages now compose DatRichText.Compose's result ONCE inside their already revision-gated Refresh, caching the built line list instead of re-wrapping on every draw call. - F14: documented (not changed) why PrivateEntityViewportRenderer's _animatedIds set carrying a reserved-but-never-drawn backdrop id is harmless — BuildDrawEntities already excludes a null/empty backdrop from the actual draw list, so the id is never looked up. - F16: the Summary preview now uses its own render-id pair (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/ 0xDA11D036) instead of sharing the Appearance page's (0xDA11D032/0xDA11D034) — confirmed by tracing FixedEntityTextureOwnerLease through TextureCache to CompositeTextureArrayCache's shared owner tracker that both pages' previews share ONE process-wide TextureCache, so sharing render ids was a real cross-page texture-release collision (either page's own re-dress or disposal could release the OTHER page's still-active textures), not a theoretical one. F3's own register bookkeeping (AP-229 addendum) and F12's register/AD header-count corrections land in the docs-only commit alongside F15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
825 lines
39 KiB
C#
825 lines
39 KiB
C#
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>
|
||
public static ImportedLayout Build(
|
||
ElementInfo rootInfo,
|
||
Func<uint, (uint, int, int)> resolve,
|
||
UiDatFont? datFont,
|
||
Func<uint, UiDatFont?>? fontResolve = null,
|
||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||
{
|
||
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);
|
||
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)
|
||
{
|
||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
|
||
if (w is null) return null; // Type-12 style prototype — skip
|
||
|
||
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
|
||
// doc comment for why this does NOT set Visible here.
|
||
w.AuthoredInvisible = info.Invisible;
|
||
|
||
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);
|
||
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);
|
||
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);
|
||
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);
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
// ── 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 (0–255); 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;
|
||
}
|
||
}
|