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;
+ }
+}