From 97bd1d2f090d0673622724e3af4168a2edf4c030 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 17:31:55 +0200 Subject: [PATCH] feat(D.2b): controls.ini stylesheet loader + apply title color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ControlsIni — a minimal flat-INI reader for retail's controls.ini (#AARRGGBB alpha-first color tokens; case-insensitive section/key lookup; missing file returns an empty sheet with no throw). Wires the [title] color token into the vitals panel's UiLabel in GameWindow.OnLoad, with hardcoded white as the fallback. Visually a no-op (retail's [title] color is white), but proves the stylesheet plumbing end-to-end (D.2b §7). Three unit tests cover section parsing, #AARRGGBB decode, and graceful missing-file handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++- src/AcDream.App/UI/ControlsIni.cs | 65 +++++++++++++++++++ .../AcDream.App.Tests/UI/ControlsIniTests.cs | 38 +++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/UI/ControlsIni.cs create mode 100644 tests/AcDream.App.Tests/UI/ControlsIniTests.cs 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 + } +}