diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 059ee654..d4df6589 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -9,8 +9,10 @@ namespace AcDream.App.UI.Layout;
/// .
///
///
-/// Type 12 (style prototype / BaseElement store) is never instantiated —
-/// returns null and the importer skips it.
+/// Type 12 elements that carry NO own state media (pure style prototypes /
+/// BaseElement stores) return null from and are skipped.
+/// Type 12 elements that DO carry own sprites (e.g. a chat element whose Type-0
+/// derived form inherited Type 12 from its base prototype) are rendered normally.
/// See docs/research/2026-06-15-layoutdesc-format.md Correction 8.
///
///
@@ -42,14 +44,16 @@ public static class DatWidgetFactory
/// 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).
+ /// The widget, or null for a pure Type-12 style prototype with no own sprites (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;
+ // Type 12 = style prototype / BaseElement store referenced by BaseLayoutId.
+ // PURE prototypes (no own state media) are property bags — never rendered; skip them.
+ // A Type-12 element that carries its own state media (e.g. a chat Send button whose
+ // Type-0 derived element inherited Type 12 from its base prototype) has sprites to
+ // show and must render. See format doc §8 and the G1 task note.
+ if (info.Type == 12 && info.StateMedia.Count == 0) return null;
UiElement e = info.Type switch
{
diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
index 061d59e9..93a4eb30 100644
--- a/src/AcDream.App/UI/Layout/ElementReader.cs
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -53,6 +53,14 @@ public sealed class ElementInfo
///
public Dictionary StateMedia = new();
+ ///
+ /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString().
+ /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set).
+ /// Used by to pick which state's sprite to render initially.
+ /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState).
+ ///
+ public string DefaultStateName = "";
+
///
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
@@ -144,7 +152,9 @@ public static class ElementReader
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
- FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
+ FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
+ // DefaultStateName: derived wins if set; otherwise inherit the base's default.
+ DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 9f5d439b..018cbb07 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -208,19 +208,23 @@ public static class LayoutImporter
///
private static ElementInfo ToInfo(ElementDesc d)
{
+ // Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
+ // no default is set; map those to "" so UiDatElement treats them as "no preference".
+ var defState = d.DefaultState.ToString();
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,
+ 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,
+ DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// DirectState (unnamed, key "").
diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs
index 0da6a067..61f7c6b3 100644
--- a/src/AcDream.App/UI/Layout/UiDatElement.cs
+++ b/src/AcDream.App/UI/Layout/UiDatElement.cs
@@ -54,6 +54,15 @@ public sealed class UiDatElement : UiElement
_info = info;
_resolve = resolve;
ClickThrough = true; // generic decoration; behavioral widgets opt back in
+
+ // Pick the initial active state: retail applies DefaultState when set; falls back
+ // to "Normal" when the element has a Normal-state sprite (retail's implicit default
+ // for stateful elements like tabs and buttons); else the unnamed DirectState ("").
+ if (!string.IsNullOrEmpty(info.DefaultStateName))
+ ActiveState = info.DefaultStateName;
+ else if (info.StateMedia.ContainsKey("Normal"))
+ ActiveState = "Normal";
+ // else ActiveState stays "" (DirectState)
}
///
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index 15dc8355..31b449bd 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -71,6 +71,32 @@ public class DatWidgetFactoryTests
Assert.Equal(7, e!.ZOrder);
}
+ // ── Test G1a: Type 12 with own sprites renders; without sprites is skipped ──
+
+ ///
+ /// Task G1 change 1: only PURE Type-12 prototypes (no state media) are skipped.
+ /// A Type-12 element that carries its own state media must return a non-null widget.
+ ///
+ [Fact]
+ public void DatWidgetFactory_Type12WithMedia_Renders()
+ {
+ // Type 12 with a "Normal" state sprite — must render (NOT skipped).
+ var withMedia = new ElementInfo
+ {
+ Type = 12,
+ Width = 32,
+ Height = 16,
+ StateMedia = { ["Normal"] = (0x00001234u, 1) },
+ };
+ var e = DatWidgetFactory.Create(withMedia, NoTex, null);
+ Assert.NotNull(e);
+ Assert.IsType(e);
+
+ // Type 12 with NO state media — must still be skipped (pure prototype).
+ var noMedia = new ElementInfo { Type = 12 };
+ Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null));
+ }
+
// ── Test 6: Meter slice extraction (the important one) ───────────────────
///
diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
index 366f51c0..3f3ef20b 100644
--- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
@@ -33,4 +33,58 @@ public class UiDatElementTests
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" };
Assert.Equal(0x06000005u, e.ActiveMedia().File);
}
+
+ // ── G1 tests: DefaultStateName + "Normal" implicit default ───────────────
+
+ ///
+ /// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal"
+ /// state sprite, the ctor should default ActiveState to "Normal" so the element
+ /// renders its normal-state sprite without requiring explicit state assignment.
+ ///
+ [Fact]
+ public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent()
+ {
+ var info = new ElementInfo();
+ info.StateMedia["Normal"] = (0x0000AAAAu, 1);
+ info.StateMedia["Hover"] = (0x0000BBBBu, 1);
+
+ var e = new UiDatElement(info, _ => (0, 0, 0));
+
+ // Should have defaulted to "Normal" state.
+ Assert.Equal(0x0000AAAAu, e.ActiveMedia().File);
+ }
+
+ ///
+ /// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"),
+ /// it takes priority over the "Normal" implicit default.
+ ///
+ [Fact]
+ public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet()
+ {
+ var info = new ElementInfo { DefaultStateName = "Minimized" };
+ info.StateMedia["Minimized"] = (0x0000BBBBu, 1);
+ info.StateMedia["Maximized"] = (0x0000CCCCu, 1);
+ info.StateMedia["Normal"] = (0x0000DDDDu, 1);
+
+ var e = new UiDatElement(info, _ => (0, 0, 0));
+
+ // DefaultStateName "Minimized" wins over "Normal" implicit default.
+ Assert.Equal(0x0000BBBBu, e.ActiveMedia().File);
+ }
+
+ ///
+ /// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state
+ /// should still default to "" (DirectState) — no regression for chrome/grip elements.
+ ///
+ [Fact]
+ public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState()
+ {
+ var info = new ElementInfo();
+ info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner)
+
+ var e = new UiDatElement(info, _ => (0, 0, 0));
+
+ // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
+ Assert.Equal(0x06007777u, e.ActiveMedia().File);
+ }
}