feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree
Implements Task 5 of the LayoutDesc Importer (Plan 1 — vitals conformance). Pure layer (BuildFromInfos / Build): - ImportedLayout result type: UiElement root + O(1) FindElement(uint id) lookup - BuildWidget dispatches via DatWidgetFactory.Create; skips Type-12 prototypes (null) - Meters consume their children (DatWidgetFactory already extracted slice ids — adding the dat children as UiElement nodes would duplicate geometry) - All other element types recurse children generically via AddChild Dat shell (Import): - Loads LayoutDesc from dats; null-safe if layout is absent - Resolves each top-level ElementDesc to ElementInfo via Resolve(): BaseElement/BaseLayoutId chain with (layoutId,elementId) cycle guard - ToInfo(): reads ElementDesc scalar fields (uint → float cast) + DirectState + named States (UIStateId.ToString() as key) - ReadState(): extracts first MediaDescImage (File + DrawMode) per state + font DID from Properties[0x1A] → ArrayBaseProperty → DataIdBaseProperty.Value - Each sibling element gets a fresh base-chain set (siblings don't share guards) DRW API: all members confirmed from VitalsLayoutDump.cs usings — no adjustments needed: LayoutDesc in DBObjs; ElementDesc/StateDesc/MediaDescImage/ ArrayBaseProperty/DataIdBaseProperty in Types; DrawModeType/UIStateId in Enums. Tests (3/3 green): - BuildFromInfos_HealthMeter_IsUiMeterAtRect — Type-7 child → UiMeter, Left=5, Width=150 - BuildFromInfos_Type12Child_IsSkipped_Type3Present — prototype absent, container present - BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree — meter findable, both dat-children absent, UiMeter.Children empty Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fc79fd519d
commit
bd01a29eb2
2 changed files with 383 additions and 0 deletions
278
src/AcDream.App/UI/Layout/LayoutImporter.cs
Normal file
278
src/AcDream.App/UI/Layout/LayoutImporter.cs
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
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)
|
||||||
|
?? 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: 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 ld = dats.Get<LayoutDesc>(layoutId);
|
||||||
|
if (ld is null) return null;
|
||||||
|
|
||||||
|
// Build a resolved ElementInfo for every top-level element in the layout.
|
||||||
|
var tops = new List<ElementInfo>();
|
||||||
|
foreach (var kv in ld.Elements)
|
||||||
|
tops.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
|
||||||
|
|
||||||
|
// If there is exactly one top-level element use it directly as the root;
|
||||||
|
// otherwise wrap the tops in a synthetic zero-id container.
|
||||||
|
ElementInfo rootInfo = tops.Count == 1
|
||||||
|
? tops[0]
|
||||||
|
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
// First MediaDescImage in this state's Media list wins (format doc §5).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs
Normal file
105
tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure unit tests for <see cref="LayoutImporter.BuildFromInfos"/> — no dats, no GL.
|
||||||
|
/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption.
|
||||||
|
/// </summary>
|
||||||
|
public class LayoutImporterTests
|
||||||
|
{
|
||||||
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||||
|
|
||||||
|
// ── Test 1: Health meter element → UiMeter with correct rect ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter
|
||||||
|
/// that is findable by its id, positioned at Left=5, Width=150.
|
||||||
|
/// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
|
||||||
|
{
|
||||||
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||||
|
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
|
||||||
|
|
||||||
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null);
|
||||||
|
|
||||||
|
var found = tree.FindElement(0x100000E6);
|
||||||
|
Assert.IsType<UiMeter>(found);
|
||||||
|
Assert.Equal(5f, found!.Left);
|
||||||
|
Assert.Equal(150f, found.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 2: Type-12 child is skipped; Type-3 sibling is present ──────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A root with two children: one Type-12 style prototype and one Type-3 container.
|
||||||
|
/// The Type-12 must be absent from the tree (FindElement returns null);
|
||||||
|
/// the Type-3 must be present.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
|
||||||
|
{
|
||||||
|
var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 };
|
||||||
|
var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 };
|
||||||
|
var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 };
|
||||||
|
|
||||||
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null);
|
||||||
|
|
||||||
|
// Type-12 must be absent.
|
||||||
|
Assert.Null(tree.FindElement(0x20000001));
|
||||||
|
// Type-3 must be present.
|
||||||
|
Assert.NotNull(tree.FindElement(0x20000002));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 3: Meter consumes its children — child ids not in byId ──────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A meter (Type 7) whose children are the 3-slice back/front containers.
|
||||||
|
/// The meter itself must be findable; its direct children must NOT appear as
|
||||||
|
/// separate nodes in the tree (meters own their children, not the generic tree).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree()
|
||||||
|
{
|
||||||
|
const uint MeterId = 0x100000E6u;
|
||||||
|
const uint BackLayerId = 0x100000E7u;
|
||||||
|
const uint FrontLayerId = 0x00000002u;
|
||||||
|
|
||||||
|
// Build a minimal meter with back + front containers, each with 3 slice children.
|
||||||
|
var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0,
|
||||||
|
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
|
||||||
|
var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1,
|
||||||
|
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
|
||||||
|
|
||||||
|
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
|
||||||
|
meter.Children.Add(backContainer);
|
||||||
|
meter.Children.Add(frontContainer);
|
||||||
|
|
||||||
|
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
|
||||||
|
|
||||||
|
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
|
||||||
|
|
||||||
|
// The meter widget is present.
|
||||||
|
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
|
||||||
|
// The meter's dat-children are NOT separate UiElement nodes.
|
||||||
|
Assert.Null(tree.FindElement(BackLayerId));
|
||||||
|
Assert.Null(tree.FindElement(FrontLayerId));
|
||||||
|
// The UiMeter itself has no Ui children (meters consume their children internally).
|
||||||
|
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
|
||||||
|
Assert.Empty(uiMeter.Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)
|
||||||
|
{
|
||||||
|
var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder };
|
||||||
|
c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } });
|
||||||
|
c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } });
|
||||||
|
c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue