diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index d50c6b46..64eac77a 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -50,6 +50,11 @@
PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b8bdbd66..74b8a5d5 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1753,42 +1753,11 @@ public sealed class GameWindow : IDisposable
var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
- var titleColor = controls.TryColor("title", "color", out var tc)
- ? tc : new System.Numerics.Vector4(1f, 1f, 1f, 1f);
-
- var panel = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
- { Left = 10, Top = 30, Width = 220, Height = 96 };
- panel.AddChild(new AcDream.App.UI.UiLabel
- {
- Text = "Vitals", Left = 8, Top = 4,
- TextColor = titleColor,
- });
-
- var vm = _vitalsVm!;
- panel.AddChild(new AcDream.App.UI.UiMeter
- {
- Left = 8, Top = 24, Width = 200, Height = 14,
- BarColor = new System.Numerics.Vector4(0.78f, 0.05f, 0.05f, 1f), // health red
- Fill = () => vm.HealthPercent,
- Label = () => (vm.HealthCurrent, vm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : null,
- });
- panel.AddChild(new AcDream.App.UI.UiMeter
- {
- Left = 8, Top = 44, Width = 200, Height = 14,
- BarColor = new System.Numerics.Vector4(0.83f, 0.62f, 0.12f, 1f), // stamina gold (retail; not cyan)
- Fill = () => vm.StaminaPercent,
- Label = () => (vm.StaminaCurrent, vm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : null,
- });
- panel.AddChild(new AcDream.App.UI.UiMeter
- {
- Left = 8, Top = 64, Width = 200, Height = 14,
- BarColor = new System.Numerics.Vector4(0.12f, 0.20f, 0.85f, 1f), // mana blue
- Fill = () => vm.ManaPercent,
- Label = () => (vm.ManaCurrent, vm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : null,
- });
-
+ string vitalsXml = System.IO.File.ReadAllText(
+ System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"));
+ var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
_uiHost.Root.AddChild(panel);
- Console.WriteLine("[D.2b] retail UI active — vitals panel wired (render-only).");
+ Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
new file mode 100644
index 00000000..d4b0cb42
--- /dev/null
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Globalization;
+using System.Numerics;
+using System.Reflection;
+using System.Xml.Linq;
+
+namespace AcDream.App.UI;
+
+///
+/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
+/// into a live subtree. {Binding} attribute
+/// values resolve against a supplied object by property name (reflection).
+/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
+///
+public static class MarkupDocument
+{
+ /// Raw XML markup for a single panel.
+ /// Object whose public properties are bound to {PropName} attributes.
+ /// Surface id → (GL handle, width, height) for chrome sprites.
+ /// Optional controls.ini stylesheet for the title color.
+ public static UiNineSlicePanel Build(
+ string xml, object binding, Func resolve,
+ ControlsIni? style = null)
+ {
+ var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
+ if (root.Name.LocalName != "panel")
+ throw new FormatException($"root must be , got <{root.Name.LocalName}>");
+
+ var panel = new UiNineSlicePanel(resolve)
+ {
+ Left = F(root, "x"),
+ Top = F(root, "y"),
+ Width = F(root, "w"),
+ Height = F(root, "h"),
+ };
+
+ string? title = (string?)root.Attribute("title");
+ if (!string.IsNullOrEmpty(title))
+ {
+ Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
+ panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc });
+ }
+
+ foreach (var el in root.Elements())
+ {
+ switch (el.Name.LocalName)
+ {
+ case "meter":
+ var cur = BindUint((string?)el.Attribute("cur"), binding);
+ var max = BindUint((string?)el.Attribute("max"), binding);
+ panel.AddChild(new UiMeter
+ {
+ Left = F(el, "x"),
+ Top = F(el, "y"),
+ Width = F(el, "w"),
+ Height = F(el, "h"),
+ BarColor = Color((string?)el.Attribute("color")),
+ Fill = BindFloat((string?)el.Attribute("fill"), binding),
+ Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
+ });
+ break;
+ // future element kinds (label, button, image) added here
+ }
+ }
+ return panel;
+ }
+
+ private static float F(XElement e, string attr)
+ => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var v) ? v : 0f;
+
+ ///
+ /// Parses #AARRGGBB → RGBA (alpha first, matching
+ /// controls.ini convention). Falls back to opaque white on bad input.
+ ///
+ private static Vector4 Color(string? hex)
+ {
+ if (hex is { Length: 9 } && hex[0] == '#'
+ && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture, out uint argb))
+ return new Vector4(
+ ((argb >> 16) & 0xFF) / 255f,
+ ((argb >> 8) & 0xFF) / 255f,
+ (argb & 0xFF) / 255f,
+ ((argb >> 24) & 0xFF) / 255f);
+ return Vector4.One;
+ }
+
+ private static Func BindFloat(string? expr, object binding)
+ {
+ var pi = Prop(expr, binding);
+ if (pi is null) return () => 0f;
+ return () => pi.GetValue(binding) switch
+ {
+ float f => f,
+ null => (float?)null,
+ var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
+ };
+ }
+
+ private static Func BindUint(string? expr, object binding)
+ {
+ var pi = Prop(expr, binding);
+ if (pi is null) return () => null;
+ return () => pi.GetValue(binding) switch
+ {
+ uint u => u,
+ null => (uint?)null,
+ var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
+ };
+ }
+
+ private static PropertyInfo? Prop(string? expr, object binding)
+ {
+ if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
+ return binding.GetType().GetProperty(expr[1..^1]);
+ }
+}
diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml
new file mode 100644
index 00000000..868926d4
--- /dev/null
+++ b/src/AcDream.App/UI/assets/vitals.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
new file mode 100644
index 00000000..8ba52d27
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
@@ -0,0 +1,50 @@
+using AcDream.App.UI;
+
+namespace AcDream.App.Tests.UI;
+
+public class MarkupDocumentTests
+{
+ private sealed class FakeBinding
+ {
+ public float HealthPercent => 0.5f;
+ public uint? HealthCurrent => 109;
+ public uint? HealthMax => 218;
+ public float? ManaPercent => null;
+ public uint? ManaCurrent => null;
+ public uint? ManaMax => null;
+ }
+
+ [Fact]
+ public void Build_CreatesPanelWithMeterFillLabelAndGeometry()
+ {
+ const string xml =
+ "" +
+ " " +
+ "";
+
+ var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
+
+ Assert.IsType(panel);
+ Assert.Equal(10f, panel.Left);
+ Assert.Equal(220f, panel.Width);
+ Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter
+ var meter = Assert.IsType(panel.Children[1]);
+ Assert.Equal(8f, meter.Left);
+ Assert.Equal(200f, meter.Width);
+ Assert.Equal(0.5f, meter.Fill());
+ Assert.Equal("109/218", meter.Label());
+ }
+
+ [Fact]
+ public void Build_NullBindingValuesYieldNullFillAndLabel()
+ {
+ const string xml =
+ "" +
+ " " +
+ "";
+ var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32));
+ var meter = Assert.IsType(panel.Children[1]);
+ Assert.Null(meter.Fill());
+ Assert.Null(meter.Label());
+ }
+}