acdream/src/AcDream.App/UI/Layout/LayoutImporter.cs
Erik bd359d5181 fix(chargen): Campaign CC gate round 1 closeout — Group 3: round review fixes (F4-F11, F14, F16)
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>
2026-08-16 15:34:07 +02:00

825 lines
39 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>
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 (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;
}
}