LayoutImporter.BuildWidget gains a UiMeter-specific branch after the ConsumesDatChildren gate: Type-3 slice containers (already consumed by DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all other children (Type-12 UIElement_Text overlays) are built normally, registered in byId, and attached as UiElement children of the meter. This fixes the XP meter (0x10000236): its two Type-12 children 0x10000237 "XP for next level:" label 0x10000238 XP-to-next-level value are now real UiText widgets in the tree, findable via FindElement. CharacterStatController.Bind now uses FindElement + LinesProvider binding instead of injecting runtime AddChild nodes. The two previously-injected UiText overlays are removed; the controller binds Padding=0, ClickThrough, RightAligned on the dat-origin widgets instead. New constants XpNextLabelId + XpNextValueId replace the old comment block. TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is NOT safe globally (risks chat/vitals) and the page-visibility pass in CharacterStatController depends on tab group Children.Count==0. Tab sprite injection onto layout.Root stays and is explicitly commented as deliberate. VITALS SAFE: health/stamina/mana meters have only Type-3 children → new loop finds nothing → zero UiElement children added → byte-identical render. VitalsController.BindMeter AddChild(number) pattern unchanged. Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children, vitals regression guard) + 3 CharacterStatControllerTests (label bound, value bound, missing children no-throw). 715 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
584 lines
28 KiB
C#
584 lines
28 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
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)
|
||
{
|
||
rootInfo.Children = new List<ElementInfo>(children);
|
||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
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, 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,
|
||
Dictionary<uint, UiElement> byId)
|
||
{
|
||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
|
||
if (w is null) return null; // Type-12 style prototype — skip
|
||
|
||
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, 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, byId);
|
||
if (cw is not null) w.AddChild(cw);
|
||
}
|
||
}
|
||
|
||
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(DatCollection 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)>()));
|
||
}
|
||
|
||
return tops.Count == 1
|
||
? tops[0]
|
||
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||
}
|
||
|
||
/// <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(
|
||
DatCollection 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;
|
||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||
}
|
||
|
||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||
|
||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
|
||
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
|
||
/// (close button / title), elements with their own children, and childless style
|
||
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</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(
|
||
DatCollection 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;
|
||
List<ElementInfo>? baseChildren = 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).
|
||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||
// Derived fields override the base; children are attached below.
|
||
result = ElementReader.Merge(baseInfo, self);
|
||
baseChildren = baseInfo.Children; // capture for the sub-window mount
|
||
}
|
||
}
|
||
|
||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||
// the cycle guard is per-element, not shared across siblings.
|
||
foreach (var kv in d.Children)
|
||
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||
|
||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
|
||
// inert for media-bearing inheritors (close button/title) and childless style prototypes
|
||
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
|
||
if (baseChildren is not null
|
||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
|
||
{
|
||
result.Children.AddRange(baseChildren);
|
||
|
||
// 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;
|
||
|
||
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
|
||
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
|
||
// anchored elements must be resized to fill the slot. Without this step, the
|
||
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
|
||
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
|
||
// within it with Bottom anchor stays at its design size too (list box at 160px
|
||
// instead of the correct 398px).
|
||
//
|
||
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
|
||
// new parent height down the whole tree, updating each Bottom-anchored child's
|
||
// rect by maintaining its bottom margin. We replicate that cascade here at import
|
||
// time on the base-children subtree so the anchor-capture during the first render
|
||
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
|
||
// margin.
|
||
//
|
||
// Safe guard: only fire when the slot has explicit size AND the base children are
|
||
// smaller (i.e., this is the "slot bigger than design" case).
|
||
// Inventory/paperdoll slots are unaffected because their slot H already matches the
|
||
// sub-layout design H, so no cascade occurs.
|
||
if (result.Width > 0 && result.Height > 0)
|
||
{
|
||
foreach (var child in baseChildren)
|
||
{
|
||
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
|
||
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||
if (child.X == 0 && child.Y == 0
|
||
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
|
||
&& child.Width <= result.Width
|
||
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
|
||
{
|
||
// Cascade UpdateForParentSizeChange down the subtree: maintain each
|
||
// Bottom-anchored element's bottom margin while growing the parent height.
|
||
CascadeHeight(child, child.Height, result.Height);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||
};
|
||
|
||
// DirectState (unnamed, key "").
|
||
if (d.StateDesc is not null)
|
||
ReadState(d.StateDesc, "", info);
|
||
|
||
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
|
||
foreach (var s in d.States)
|
||
ReadState(s.Value, s.Key.ToString(), info);
|
||
|
||
return info;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
|
||
/// <c>info.StateMedia[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, string name, ElementInfo info)
|
||
{
|
||
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
|
||
// are intentionally skipped — cursor behavior is Plan 2.
|
||
foreach (var m in sd.Media)
|
||
{
|
||
if (m is MediaDescImage img && img.File != 0)
|
||
{
|
||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 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 values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
|
||
// 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 => HJustify.Left,
|
||
1u => HJustify.Center,
|
||
3u => HJustify.Right,
|
||
5u => HJustify.Right,
|
||
_ => HJustify.Center, // unknown → center (safe default)
|
||
};
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
|
||
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
|
||
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
|
||
/// retail runtime.
|
||
///
|
||
/// <para>Three anchor cases for the vertical axis:
|
||
/// <list type="bullet">
|
||
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
|
||
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
|
||
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
|
||
/// </list>
|
||
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
|
||
///
|
||
/// <para>This is called once at import time on base-children that are mounted into a
|
||
/// slot taller than the sub-layout's design height.</para>
|
||
/// </summary>
|
||
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
|
||
{
|
||
float oldH = el.Height;
|
||
float newH = el.Height;
|
||
float oldY = el.Y;
|
||
|
||
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
|
||
|
||
bool hasTop = (anchors & AnchorEdges.Top) != 0;
|
||
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
|
||
|
||
if (hasTop && hasBottom)
|
||
{
|
||
// Stretch: maintain both top and bottom margins.
|
||
// amT = el.Y (pinned to top, unchanged)
|
||
// amB = oldParentH - (el.Y + el.H)
|
||
float amB = oldParentH - (el.Y + el.Height);
|
||
newH = newParentH - el.Y - amB;
|
||
if (newH < 0f) newH = 0f;
|
||
el.Height = newH;
|
||
}
|
||
else if (hasBottom && !hasTop)
|
||
{
|
||
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
|
||
// amB = oldParentH - (el.Y + el.H)
|
||
float amB = oldParentH - (el.Y + el.Height);
|
||
float newY = newParentH - amB - el.Height;
|
||
el.Y = newY;
|
||
// Height unchanged; newH = oldH for recursion.
|
||
}
|
||
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
|
||
|
||
// Recurse into children with the element's new height as their parent.
|
||
foreach (var child in el.Children)
|
||
CascadeHeight(child, oldH, newH);
|
||
}
|
||
}
|