Task G1: two gaps blocked chat window static sprite elements from rendering. Change 1 — DatWidgetFactory: only skip Type-12 elements that have no own state media (pure style prototypes). A Type-12 element that carries sprites (e.g. a chat Send button whose derived Type-0 element inherited Type 12 from its base prototype) now renders as a UiDatElement. Change 2 — ElementInfo: add DefaultStateName field (string, default ""). Change 3 — LayoutImporter.ToInfo: read ElementDesc.DefaultState.ToString() into DefaultStateName; normalize Undef/Undefined/0 sentinels to "". Change 4 — ElementReader.Merge: inherit DefaultStateName (derived wins if non-empty, else base). Change 5 — UiDatElement ctor: initialize ActiveState to DefaultStateName when set; else "Normal" when a Normal-state sprite is present (retail's implicit default for buttons/tabs); else "" (DirectState). This makes the Send button, max/min button, and numbered tabs render their default sprite without requiring explicit state assignment at runtime. Vitals neutrality: all vitals chrome/grip elements carry DirectState-only sprites with no "Normal" named state and DefaultStateName="" (Undef in dat), so their ActiveState stays "" and their existing conformance tests are unaffected. Vitals text labels (Type 0→12 via Merge, no StateMedia) are still skipped by the refined Type-12 guard (StateMedia.Count==0). Tests: 4 new tests (2 in DatWidgetFactoryTests, 3 in UiDatElementTests). All 386 pass; 387 total (1 pre-existing skip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
12 KiB
C#
296 lines
12 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)
|
|
{
|
|
rootInfo.Children = new List<ElementInfo>(children);
|
|
return Build(rootInfo, resolve, datFont);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure builder: produce the widget tree from a fully resolved
|
|
/// <see cref="ElementInfo"/> tree (children already attached).
|
|
/// </summary>
|
|
public static ImportedLayout Build(
|
|
ElementInfo rootInfo,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? datFont)
|
|
{
|
|
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, 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,
|
|
Dictionary<uint, UiElement> byId)
|
|
{
|
|
var w = DatWidgetFactory.Create(info, resolve, datFont);
|
|
if (w is null) return null; // Type-12 style prototype — skip
|
|
|
|
if (info.Id != 0) byId[info.Id] = w;
|
|
|
|
// Meters consume their own children: DatWidgetFactory already extracted the
|
|
// slice-sprite ids from the grandchild image elements during UiMeter construction.
|
|
// Adding those children as separate UiElement nodes would produce duplicate
|
|
// geometry and wrong widget semantics. Every other element type recurses normally.
|
|
if (w is not UiMeter)
|
|
{
|
|
foreach (var child in info.Children)
|
|
{
|
|
var cw = BuildWidget(child, resolve, datFont, 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;
|
|
|
|
var tops = new List<ElementInfo>();
|
|
foreach (var kv in ld.Elements)
|
|
tops.Add(Resolve(dats, kv.Value, 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.
|
|
/// </summary>
|
|
public static ImportedLayout? Import(
|
|
DatCollection dats,
|
|
uint layoutId,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? datFont)
|
|
{
|
|
var rootInfo = ImportInfos(dats, layoutId);
|
|
if (rootInfo is null) return null;
|
|
return Build(rootInfo, resolve, datFont);
|
|
}
|
|
|
|
// ── Inheritance resolution ────────────────────────────────────────────────
|
|
|
|
/// <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;
|
|
|
|
// 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; result.Children is still empty here
|
|
// — children are attached below from the DERIVED element's own tree.
|
|
result = ElementReader.Merge(baseInfo, self);
|
|
}
|
|
}
|
|
|
|
// 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)>()));
|
|
|
|
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,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|