diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs new file mode 100644 index 00000000..ce3d1ce8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -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; + +/// +/// The result of importing a retail LayoutDesc: a tree with +/// an O(1) lookup table for finding any element by its dat id. +/// +public sealed class ImportedLayout +{ + /// Root widget of the imported tree. + public UiElement Root { get; } + + private readonly Dictionary _byId; + + public ImportedLayout(UiElement root, Dictionary byId) + { + Root = root; + _byId = byId; + } + + /// Find a widget by its dat element id (e.g. 0x100000E6). + /// Returns null if the id was skipped (Type-12 prototype) or not present. + public UiElement? FindElement(uint id) + => _byId.TryGetValue(id, out var e) ? e : null; +} + +/// +/// Two-layer layout importer for retail LayoutDesc dat objects. +/// +/// +/// Pure layer ( / ): +/// converts a pre-resolved tree into a +/// tree via . Testable without dats or OpenGL — all tests +/// in LayoutImporterTests.cs exercise this layer only. +/// +/// +/// +/// Dat shell (): reads a , +/// converts each top-level to a fully resolved +/// (applying BaseElement / BaseLayoutId +/// inheritance with a cycle guard), then delegates to . +/// +/// +/// +/// Meter elements (Type 7) consume their own dat-children: +/// reads the grandchild slice-sprite ids during construction, so the +/// children must NOT be added as separate nodes in the tree. +/// Every other element type recurses its children generically. +/// +/// +public static class LayoutImporter +{ + // ── Pure layer ──────────────────────────────────────────────────────────── + + /// + /// Convenience for tests: attach to + /// , then call . + /// The children list is set directly on ; + /// any existing children are replaced. + /// + public static ImportedLayout BuildFromInfos( + ElementInfo rootInfo, + IEnumerable children, + Func resolve, + UiDatFont? datFont) + { + rootInfo.Children = new List(children); + return Build(rootInfo, resolve, datFont); + } + + /// + /// Pure builder: produce the widget tree from a fully resolved + /// tree (children already attached). + /// + public static ImportedLayout Build( + ElementInfo rootInfo, + Func resolve, + UiDatFont? datFont) + { + var byId = new Dictionary(); + // 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 resolve, + UiDatFont? datFont, + Dictionary 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 ───────────────────────────────────────────────────────────── + + /// + /// 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. + /// + public static ImportedLayout? Import( + DatCollection dats, + uint layoutId, + Func resolve, + UiDatFont? datFont) + { + var ld = dats.Get(layoutId); + if (ld is null) return null; + + // Build a resolved ElementInfo for every top-level element in the layout. + var tops = new List(); + 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 ──────────────────────────────────────────────── + + /// + /// Converts an to a resolved : + /// reads own fields + media, applies the BaseElement / BaseLayoutId chain + /// (cycle-guarded by ), then resolves + attaches children. + /// + 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(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; + } + + /// + /// Read an 's own scalar fields + state media into a + /// fresh . No inheritance is applied; children are not + /// attached (the caller handles those). + /// + 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; + } + + /// + /// Read the first from into + /// info.StateMedia[name] and extract the font DID from property 0x1A + /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. + /// + 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 ─────────────────────────────────────────────────── + + /// + /// Find an by id anywhere in the top-level tree of + /// (depth-first). Returns null if not found. + /// + 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs new file mode 100644 index 00000000..2292aab8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs @@ -0,0 +1,105 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Pure unit tests for — no dats, no GL. +/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption. +/// +public class LayoutImporterTests +{ + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + // ── Test 1: Health meter element → UiMeter with correct rect ───────────── + + /// + /// 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)>. + /// + [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(found); + Assert.Equal(5f, found!.Left); + Assert.Equal(150f, found.Width); + } + + // ── Test 2: Type-12 child is skipped; Type-3 sibling is present ────────── + + /// + /// 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. + /// + [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 ────────── + + /// + /// 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). + /// + [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(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; + } +}