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