diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index efa13627..b8bdbd66 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1748,12 +1748,20 @@ public sealed class GameWindow : IDisposable
return (t, w, h);
}
+ // Phase D.2b โ optional retail stylesheet. controls.ini lives under
+ // the AC install (ACDREAM_AC_DIR); absent โ source-verified fallback.
+ 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 = new System.Numerics.Vector4(1f, 1f, 1f, 1f),
+ TextColor = titleColor,
});
var vm = _vitalsVm!;
diff --git a/src/AcDream.App/UI/ControlsIni.cs b/src/AcDream.App/UI/ControlsIni.cs
new file mode 100644
index 00000000..2812d696
--- /dev/null
+++ b/src/AcDream.App/UI/ControlsIni.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Numerics;
+
+namespace AcDream.App.UI;
+
+///
+/// Minimal reader for retail's controls.ini โ a flat INI with one
+/// [section] per element type. Colors are #AARRGGBB (alpha
+/// first). Optional: a missing file yields an empty sheet (callers fall back
+/// to hardcoded defaults). See the D.2b spec ยง7.
+///
+public sealed class ControlsIni
+{
+ private readonly Dictionary> _sections;
+
+ private ControlsIni(Dictionary> s) => _sections = s;
+
+ /// Load from disk; returns an empty sheet if the file is absent.
+ public static ControlsIni Load(string path)
+ => System.IO.File.Exists(path)
+ ? Parse(System.IO.File.ReadAllText(path))
+ : new ControlsIni(new());
+
+ public static ControlsIni Parse(string text)
+ {
+ var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase);
+ Dictionary? cur = null;
+ foreach (var raw in text.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue;
+ if (line[0] == '[' && line[^1] == ']')
+ {
+ var name = line[1..^1].Trim();
+ cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase);
+ sections[name] = cur;
+ continue;
+ }
+ int eq = line.IndexOf('=');
+ if (eq <= 0 || cur is null) continue;
+ cur[line[..eq].Trim()] = line[(eq + 1)..].Trim();
+ }
+ return new ControlsIni(sections);
+ }
+
+ public string? Get(string section, string key)
+ => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null;
+
+ /// Parse a #AARRGGBB token into an RGBA .
+ public bool TryColor(string section, string key, out Vector4 color)
+ {
+ color = default;
+ var v = Get(section, key);
+ if (v is null || v.Length != 9 || v[0] != '#') return false;
+ if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb))
+ return false;
+ float a = ((argb >> 24) & 0xFF) / 255f;
+ float r = ((argb >> 16) & 0xFF) / 255f;
+ float g = ((argb >> 8) & 0xFF) / 255f;
+ float b = (argb & 0xFF) / 255f;
+ color = new Vector4(r, g, b, a);
+ return true;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs
new file mode 100644
index 00000000..d4802e27
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs
@@ -0,0 +1,38 @@
+using System.Numerics;
+using AcDream.App.UI;
+
+namespace AcDream.App.Tests.UI;
+
+public class ControlsIniTests
+{
+ [Fact]
+ public void Parse_ReadsSectionTokens()
+ {
+ var ini = ControlsIni.Parse(
+ "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" +
+ "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n");
+
+ Assert.Equal("19", ini.Get("title", "height"));
+ Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font"));
+ Assert.Null(ini.Get("title", "missing"));
+ Assert.Null(ini.Get("nosuch", "height"));
+ }
+
+ [Fact]
+ public void TryColor_ParsesAlphaFirstHex()
+ {
+ var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n");
+ Assert.True(ini.TryColor("body", "color_border", out Vector4 c));
+ Assert.Equal(0xFF / 255f, c.W, 5); // alpha
+ Assert.Equal(0x4F / 255f, c.X, 5); // red
+ Assert.Equal(0x65 / 255f, c.Y, 5); // green
+ Assert.Equal(0x7D / 255f, c.Z, 5); // blue
+ }
+
+ [Fact]
+ public void Load_MissingFileReturnsEmptyNotThrow()
+ {
+ var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini");
+ Assert.Null(ini.Get("title", "height")); // empty, no throw
+ }
+}