diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
new file mode 100644
index 00000000..e1a5272e
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -0,0 +1,165 @@
+using System.Collections.Generic;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// GL-free, dat-free snapshot of a resolved layout element.
+/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc
+/// after inheritance is applied. The pure transforms on
+/// operate on this type so they can be unit-tested without the dats or OpenGL.
+///
+/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
+/// updating the plan spec and downstream consumers.
+///
+public sealed class ElementInfo
+{
+ /// Dat element id (e.g. 0x100000E6).
+ public uint Id;
+
+ ///
+ /// Raw element class id as a uint.
+ /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009
+ /// overflow int when treated as signed, so this stays uint.
+ /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
+ /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
+ ///
+ public uint Type;
+
+ /// Position and size within the parent, in pixels (cast from dat uint fields).
+ public float X, Y, Width, Height;
+
+ ///
+ /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge,
+ /// RightEdge, BottomEdge fields of ElementDesc).
+ /// Values 0–4; map to bit-flags via
+ /// .
+ ///
+ public uint Left, Top, Right, Bottom;
+
+ /// Draw order within the parent (lower = drawn first / behind).
+ public uint ReadOrder;
+
+ ///
+ /// Font dat object id inherited from the base element's Properties[0x1A]
+ /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited.
+ ///
+ public uint FontDid;
+
+ ///
+ /// Sprite per state: state name → (RenderSurface file id, DrawMode int).
+ /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc).
+ /// Named states use the UIStateId.ToString() value as the key
+ /// (e.g. "HideDetail", "ShowDetail").
+ ///
+ public Dictionary StateMedia = new();
+
+ ///
+ /// Resolved child elements (populated by the importer in Task 5).
+ /// Children come from the derived element's own tree, not the base element's.
+ ///
+ public List Children = new();
+}
+
+///
+/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
+/// All methods are static and operate on POCOs.
+/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
+/// the bit-flag enum from AcDream.App.UI.
+///
+public static class ElementReader
+{
+ ///
+ /// Maps the four raw edge-anchor flag values from ElementDesc to the
+ /// bit-flag used by the UI layout engine.
+ ///
+ ///
+ /// The dat stores one uint per edge with these semantics (§4 of the
+ /// LayoutDesc format reference, 2026-06-15):
+ ///
+ /// - 0 = no anchor (prototype-only elements — zero-size style stores)
+ /// - 1 = pinned to the near edge (left for LeftEdge, top for TopEdge)
+ /// - 2 = pinned to the far edge (right for RightEdge, bottom for BottomEdge)
+ /// - 3 = floating / centered between both far edges (maps to neither Left nor Right)
+ /// - 4 = stretch: pinned to BOTH near AND far edges simultaneously (element stretches with parent)
+ ///
+ ///
+ ///
+ ///
+ /// Default when no flags resolve: Left | Top (pin top-left, fixed size).
+ /// This matches elements whose all-zero edge flags indicate a no-reflow prototype.
+ ///
+ ///
+ /// LeftEdge dat field value (0–4).
+ /// TopEdge dat field value (0–4).
+ /// RightEdge dat field value (0–4).
+ /// BottomEdge dat field value (0–4).
+ public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
+ {
+ // 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides).
+ // Only 1 and 4 contribute the NEAR (Left/Top) anchor.
+ // Only 2 and 4 contribute the FAR (Right/Bottom) anchor.
+ // Value 3 contributes neither (floating center is handled by the UI engine differently).
+ var a = AnchorEdges.None;
+ if (left == 1 || left == 4) a |= AnchorEdges.Left;
+ if (top == 1 || top == 4) a |= AnchorEdges.Top;
+ if (right == 2 || right == 4) a |= AnchorEdges.Right;
+ if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom;
+ if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
+ return a;
+ }
+
+ ///
+ /// Merges a base element snapshot with a derived element snapshot, mirroring
+ /// the BaseElement / BaseLayoutId inheritance chain in the dat.
+ ///
+ ///
+ /// Rules:
+ ///
+ /// -
+ /// Scalar fields (, ,
+ /// , ,
+ /// ): derived wins if non-zero; otherwise
+ /// inherited from base.
+ ///
+ /// -
+ /// Position (, ) and
+ /// edge flags ( etc.) and
+ /// : always taken from the derived element
+ /// (derived placement, not the base prototype's geometry).
+ ///
+ /// -
+ /// : base entries are the default; derived
+ /// entries override (or add) per state name key.
+ ///
+ /// -
+ /// : come from the derived element's own tree only.
+ ///
+ ///
+ ///
+ ///
+ public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
+ {
+ var m = new ElementInfo
+ {
+ Id = derived.Id != 0 ? derived.Id : base_.Id,
+ Type = derived.Type != 0 ? derived.Type : base_.Type,
+ X = derived.X,
+ Y = derived.Y,
+ Width = derived.Width != 0 ? derived.Width : base_.Width,
+ Height = derived.Height != 0 ? derived.Height : base_.Height,
+ Left = derived.Left,
+ Top = derived.Top,
+ Right = derived.Right,
+ Bottom = derived.Bottom,
+ ReadOrder = derived.ReadOrder,
+ FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
+ // Children come from the derived element's own tree, not the base prototype's.
+ Children = derived.Children,
+ };
+ // Start with base StateMedia as defaults, then let derived entries override.
+ m.StateMedia = new Dictionary(base_.StateMedia);
+ foreach (var kv in derived.StateMedia)
+ m.StateMedia[kv.Key] = kv.Value;
+ return m;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
new file mode 100644
index 00000000..90b1a995
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
@@ -0,0 +1,139 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+namespace AcDream.App.Tests.UI.Layout;
+
+public class ElementReaderTests
+{
+ // ── ToAnchors ────────────────────────────────────────────────────────────
+
+ ///
+ /// Edge value 4 = stretch (pinned to BOTH near AND far sides simultaneously).
+ /// LeftEdge=4 → Left anchor; RightEdge=4 → Right anchor.
+ /// TopEdge=1 → Top only (near-pin); BottomEdge=1 → near-pin (left/top axis), NOT Bottom.
+ ///
+ [Fact]
+ public void EdgeFlagsToAnchors_LeftRight_Stretches()
+ {
+ // left=4 (stretch ⇒ Left), top=1 (near-pin ⇒ Top), right=4 (stretch ⇒ Right), bottom=1 (near-pin of bottom axis ⇒ not Bottom)
+ var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1);
+ Assert.True(a.HasFlag(AnchorEdges.Left));
+ Assert.True(a.HasFlag(AnchorEdges.Right));
+ Assert.False(a.HasFlag(AnchorEdges.Bottom));
+ }
+
+ ///
+ /// Edge value 1 = pinned to the NEAR edge of that axis.
+ /// For LeftEdge: near = Left. For TopEdge: near = Top.
+ /// For RightEdge: value 1 means near-pin of the right axis → does NOT map to Right anchor.
+ /// For BottomEdge: value 1 means near-pin of the bottom axis → does NOT map to Bottom anchor.
+ ///
+ [Fact]
+ public void EdgeFlagsToAnchors_AllOnes_PinsTopLeftOnly()
+ {
+ // 1 everywhere: only Left and Top anchors set (near-pins). Right/Bottom are far edges and value 1 is near-pin.
+ var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1);
+ Assert.True(a.HasFlag(AnchorEdges.Left));
+ Assert.True(a.HasFlag(AnchorEdges.Top));
+ Assert.False(a.HasFlag(AnchorEdges.Right));
+ Assert.False(a.HasFlag(AnchorEdges.Bottom));
+ }
+
+ ///
+ /// Edge value 2 = pinned to the FAR edge of that axis.
+ /// For RightEdge: far = Right anchor. For BottomEdge: far = Bottom anchor.
+ /// For LeftEdge: value 2 means far-pin of the left axis → does NOT map to Left anchor.
+ /// For TopEdge: value 2 means far-pin of the top axis → does NOT map to Top anchor.
+ ///
+ [Fact]
+ public void EdgeFlagsToAnchors_AllTwos_PinsRightBottomOnly()
+ {
+ // 2 everywhere: only Right and Bottom anchors set (far-pins).
+ var a = ElementReader.ToAnchors(left: 2, top: 2, right: 2, bottom: 2);
+ Assert.False(a.HasFlag(AnchorEdges.Left));
+ Assert.False(a.HasFlag(AnchorEdges.Top));
+ Assert.True(a.HasFlag(AnchorEdges.Right));
+ Assert.True(a.HasFlag(AnchorEdges.Bottom));
+ }
+
+ ///
+ /// All-zero edge flags (prototype-only elements) fall back to Left|Top default.
+ ///
+ [Fact]
+ public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft()
+ {
+ var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0);
+ Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
+ }
+
+ ///
+ /// Value 3 = floating/centered between both far edges on that axis.
+ /// Both LeftEdge=3 and RightEdge=3 → neither Left nor Right are set by the
+ /// near/stretch rules. The result is only Right+Bottom (the "far" semantics).
+ /// Specifically: left=3 → not Left (3 is not 1 or 4); right=3 → Right (3 is not 2 or 4, skip).
+ /// Wait — value 3 means "pinned to BOTH far edges" per format doc §4. Re-check the
+ /// mapping rule: Right anchor fires on right==2 || right==4, NOT on right==3.
+ /// So value 3 on LeftEdge, TopEdge, RightEdge, BottomEdge → no flags set → default Left|Top.
+ /// This test covers that corner case (element 0x100004A9 — expand-detail overlay).
+ ///
+ [Fact]
+ public void EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft()
+ {
+ // value 3 doesn't match any anchor rule; falls back to Left|Top default.
+ var a = ElementReader.ToAnchors(left: 3, top: 3, right: 3, bottom: 3);
+ Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
+ }
+
+ // ── Merge ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Merge_BaseThenOverride_DerivedWins()
+ {
+ var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 };
+ var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(200, merged.Width); // override
+ Assert.Equal(16, merged.Height); // inherited
+ Assert.Equal(0x40000000u, merged.FontDid);// inherited
+ }
+
+ [Fact]
+ public void Merge_DerivedHasFontDid_OverridesBase()
+ {
+ var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 };
+ var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 };
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(0x40000001u, merged.FontDid);
+ }
+
+ [Fact]
+ public void Merge_DerivedStateMediaOverridesBase()
+ {
+ var base_ = new ElementInfo();
+ base_.StateMedia[""] = (0x06001000u, 1);
+ base_.StateMedia["HideDetail"] = (0x06001001u, 1);
+
+ var derived = new ElementInfo();
+ derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state
+
+ var merged = ElementReader.Merge(base_, derived);
+ // derived's "" overrides base's ""
+ Assert.Equal((0x06002000u, 3), merged.StateMedia[""]);
+ // base's "HideDetail" is kept (derived didn't provide it)
+ Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
+ }
+
+ [Fact]
+ public void Merge_ChildrenComeFromDerived()
+ {
+ var base_ = new ElementInfo();
+ base_.Children.Add(new ElementInfo { Id = 0x1u });
+
+ var derived = new ElementInfo();
+ derived.Children.Add(new ElementInfo { Id = 0x2u });
+
+ var merged = ElementReader.Merge(base_, derived);
+ // children must come from derived only
+ Assert.Single(merged.Children);
+ Assert.Equal(0x2u, merged.Children[0].Id);
+ }
+}