diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
new file mode 100644
index 00000000..e8791e44
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Linq;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
+/// algorithm ports); everything else (and unknown Types) falls back to
+/// .
+///
+///
+/// Type 12 (style prototype / BaseElement store) is never instantiated —
+/// returns null and the importer skips it.
+/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8.
+///
+///
+///
+/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
+/// NOT on the meter element itself (format doc §11).
+/// walks two layers down to extract them: the two Type-3 container children
+/// ordered by (back behind = lower, front
+/// on top = higher), then within each container the image children that carry
+/// a DirectState ("" key) sprite, ordered by their X position to obtain
+/// left-cap / center-tile / right-cap.
+///
+///
+///
+/// The expand-detail overlay present in the front container carries ONLY named
+/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
+/// TryGetValue("") filter in excludes it
+/// automatically.
+///
+///
+public static class DatWidgetFactory
+{
+ ///
+ /// Creates the for , sets its
+ /// rect (Left/Top/Width/Height) and Anchors, and returns it.
+ ///
+ /// Resolved, merged element snapshot from the LayoutDesc importer.
+ /// RenderSurface id → (GL tex handle, pixel width, pixel height).
+ /// Returns (0,0,0) when the texture is not yet uploaded.
+ /// Retail UI font for the meter's "cur/max" number overlay.
+ /// May be null pre-load — the meter falls back to the debug bitmap font.
+ /// The widget, or null for a Type-12 style prototype (caller skips it).
+ public static UiElement? Create(ElementInfo info,
+ Func resolve, UiDatFont? datFont)
+ {
+ // Type 12 = zero-size style prototype / BaseElement store referenced by
+ // BaseLayoutId. These are property bags, never rendered. See format doc §8
+ // ("style prototypes are Type 12 which must be skipped") and Correction 8.
+ if (info.Type == 12) return null;
+
+ UiElement e = info.Type switch
+ {
+ 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
+ _ => new UiDatElement(info, resolve), // generic fallback for all other types
+ };
+
+ // Propagate position + size (pixel-exact from the dat).
+ e.Left = info.X;
+ e.Top = info.Y;
+ e.Width = info.Width;
+ e.Height = info.Height;
+
+ // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
+ // UI layout engine uses for reflow.
+ e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
+
+ return e;
+ }
+
+ // ── Meter ────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds a and populates its six 3-slice sprite ids by
+ /// reading the meter's grandchild image elements (format doc §11).
+ ///
+ ///
+ /// Structure the importer produces for each meter (UIElement_Meter):
+ ///
+ /// meter (Type 7)
+ /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
+ /// │ ├── left-cap image (DirectState "" → File = back-left sprite)
+ /// │ ├── center image (DirectState "" → File = back-tile sprite)
+ /// │ └── right-cap image (DirectState "" → File = back-right sprite)
+ /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
+ /// │ ├── left-cap image (→ front-left sprite)
+ /// │ ├── center image (→ front-tile sprite)
+ /// │ ├── right-cap image (→ front-right sprite)
+ /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
+ /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController in Task 6)
+ ///
+ ///
+ ///
+ ///
+ /// and are NOT set here.
+ /// They are bound to the live stat providers in Task 6 (VitalsController).
+ ///
+ ///
+ private static UiMeter BuildMeter(ElementInfo info,
+ Func resolve, UiDatFont? datFont)
+ {
+ var m = new UiMeter
+ {
+ SpriteResolve = resolve,
+ DatFont = datFont,
+ };
+
+ // The two 3-slice containers are Type-3 children of the meter element.
+ // ReadOrder determines draw order: the back track has a LOWER ReadOrder
+ // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
+ var containers = info.Children
+ .Where(c => c.Type == 3)
+ .OrderBy(c => c.ReadOrder)
+ .ToList();
+
+ if (containers.Count >= 1)
+ {
+ var (l, t, r) = SliceIds(containers[0]);
+ m.BackLeft = l;
+ m.BackTile = t;
+ m.BackRight = r;
+ }
+
+ if (containers.Count >= 2)
+ {
+ var (l, t, r) = SliceIds(containers[1]);
+ m.FrontLeft = l;
+ m.FrontTile = t;
+ m.FrontRight = r;
+ }
+
+ return m;
+ }
+
+ ///
+ /// Returns the (left, tile, right) sprite ids for a 3-slice container,
+ /// extracting them from the container's image children that carry a DirectState
+ /// ("" key) with a non-zero file id, ordered left-to-right by their X position.
+ ///
+ ///
+ /// Children that carry ONLY named states (e.g. the expand-detail overlay with
+ /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
+ /// because for "" returns
+ /// false.
+ ///
+ ///
+ private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
+ {
+ // Only children that have a non-zero DirectState image are slice candidates.
+ // The expand-detail overlay has NO DirectState entry, so it's excluded here.
+ var slices = container.Children
+ .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
+ .OrderBy(c => c.X)
+ .ToList();
+
+ static uint File(ElementInfo e)
+ => e.StateMedia.TryGetValue("", out var med) ? med.File : 0u;
+
+ uint left = slices.Count > 0 ? File(slices[0]) : 0u;
+ uint tile = slices.Count > 1 ? File(slices[1]) : 0u;
+ uint right = slices.Count > 2 ? File(slices[2]) : 0u;
+
+ return (left, tile, right);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
new file mode 100644
index 00000000..6a1ef9c1
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -0,0 +1,112 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+namespace AcDream.App.Tests.UI.Layout;
+
+public class DatWidgetFactoryTests
+{
+ private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
+
+ // ── Test 1: Type 7 → UiMeter ─────────────────────────────────────────────
+
+ [Fact]
+ public void Type7_Meter_MakesUiMeter()
+ {
+ var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
+ Assert.IsType(e);
+ }
+
+ // ── Test 2: Unknown type → UiDatElement fallback ─────────────────────────
+
+ [Fact]
+ public void UnknownType_FallsBackToGeneric()
+ {
+ var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
+ Assert.IsType(e);
+ }
+
+ // ── Test 3: Type 12 → null (style prototype, never rendered) ─────────────
+
+ [Fact]
+ public void Type12_StylePrototype_ReturnsNull()
+ {
+ var e = DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null);
+ Assert.Null(e);
+ }
+
+ // ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
+
+ ///
+ /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=2 should have
+ /// its rect + anchors copied onto the returned widget.
+ /// Left=1 (near-pin → AnchorEdges.Left), Top=1 (near-pin → AnchorEdges.Top),
+ /// Right=2 (far-pin → AnchorEdges.Right), Bottom=0 (no anchor → neither).
+ /// Combined: Left | Top | Right.
+ ///
+ [Fact]
+ public void RectAndAnchors_SetFromElementInfo()
+ {
+ var info = new ElementInfo
+ {
+ Type = 3,
+ X = 5, Y = 21,
+ Width = 150, Height = 16,
+ Left = 1, Top = 1,
+ Right = 2, Bottom = 0,
+ };
+ var e = DatWidgetFactory.Create(info, NoTex, null)!;
+ Assert.Equal(5f, e.Left);
+ Assert.Equal(21f, e.Top);
+ Assert.Equal(150f, e.Width);
+ Assert.Equal(16f, e.Height);
+ Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
+ }
+
+ // ── Test 5: Meter slice extraction (the important one) ───────────────────
+
+ ///
+ /// A meter (Type 7) whose two Type-3 containers each carry 3 image children
+ /// (ordered by X, bearing a DirectState "" sprite), plus the front container
+ /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state —
+ /// that overlay must be excluded from the slice count.
+ ///
+ [Fact]
+ public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay()
+ {
+ // Slice ids sourced from format doc §11 — real health-bar ids.
+ const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u;
+ const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u;
+ const uint OverlayFile = 0x06007490u;
+
+ // Back container (ReadOrder 0 — drawn first / behind)
+ var backChild = new ElementInfo { Type = 3, ReadOrder = 0 };
+ backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } });
+ backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } });
+ backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } });
+
+ // Front container (ReadOrder 1 — drawn on top)
+ var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 };
+ frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } });
+ frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } });
+ frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } });
+ // Expand-detail overlay: named state only — NO DirectState "" — must be ignored.
+ frontChild.Children.Add(new ElementInfo
+ {
+ X = 0,
+ StateMedia = { ["ShowDetail"] = (OverlayFile, 3) }
+ });
+
+ var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 };
+ meter.Children.Add(backChild);
+ meter.Children.Add(frontChild);
+
+ var e = DatWidgetFactory.Create(meter, NoTex, null);
+
+ var m = Assert.IsType(e);
+ Assert.Equal(BackL, m.BackLeft);
+ Assert.Equal(BackT, m.BackTile);
+ Assert.Equal(BackR, m.BackRight);
+ Assert.Equal(FrontL, m.FrontLeft);
+ Assert.Equal(FrontT, m.FrontTile);
+ Assert.Equal(FrontR, m.FrontRight);
+ }
+}