From df6b5b3b3919a8e85ba598c51ccc897ec293228c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 25 Jun 2026 14:26:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(studio):=20StudioWindow=20+=20LayoutSource?= =?UTF-8?q?=20=E2=80=94=20render=20a=20dat=20panel=20standalone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the acdream UI Studio plan. Adds three new files under src/AcDream.App/Studio/ and one new test file: - StudioOptions.cs: record + Parse() for the ui-studio CLI args (positional dat dir or ACDREAM_DAT_DIR, --layout 0xNNNN, --markup ; defaults to vitals 0x2100006C when neither layout nor markup given). - LayoutSource.cs: wraps LayoutImporter.Import for dat-backed layouts; markup path sets LastError "markup unsupported (Task 6)" and returns null for now. Exposes Kind / LayoutId / MarkupPath / LastError / CurrentLayout / Reload(). - StudioWindow.cs: Silk.NET 1280×720 GL 4.3 window; boots RenderBootstrap, wires input to UiHost, loads the panel via LayoutSource, adds the root to UiHost.Root.AddChild. QualitySettings resolved the same way GameWindow.Run() does (SettingsStore → QualitySettings.From → WithEnvOverrides). - Program.cs: ui-studio dispatch at the top of top-level statements (before the dat-dir parse) so `dotnet run -- ui-studio` routes to StudioWindow. - LayoutSourceTests.cs: dat-gated test (skips when dats absent); verifies that the vitals LayoutDesc (0x2100006C) loads, root is non-null, byId contains the vitals root element (0x100005F9), and Kind == DatLayout. Passes (1/1 with dats present; silently skips on CI). Note: the spec asserts FindElement(0x2100006Cu) — that id is the LayoutDesc dat id, not a widget element id. The actual vitals root element id is 0x100005F9 (confirmed from the vitals_2100006C.json fixture). The test uses the correct element id and documents the discrepancy. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Program.cs | 8 + src/AcDream.App/Studio/LayoutSource.cs | 109 +++++++++++++ src/AcDream.App/Studio/StudioOptions.cs | 60 +++++++ src/AcDream.App/Studio/StudioWindow.cs | 150 ++++++++++++++++++ .../Studio/LayoutSourceTests.cs | 60 +++++++ 5 files changed, 387 insertions(+) create mode 100644 src/AcDream.App/Studio/LayoutSource.cs create mode 100644 src/AcDream.App/Studio/StudioOptions.cs create mode 100644 src/AcDream.App/Studio/StudioWindow.cs create mode 100644 tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index b3aebd5a..9e48adbb 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -4,6 +4,14 @@ using AcDream.App.Rendering; using AcDream.Core.Plugins; using Serilog; +if (args.Length >= 1 && args[0] == "ui-studio") +{ + var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); + using var sw = new AcDream.App.Studio.StudioWindow(so); + sw.Run(); + return 0; +} + Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() diff --git a/src/AcDream.App/Studio/LayoutSource.cs b/src/AcDream.App/Studio/LayoutSource.cs new file mode 100644 index 00000000..6b125fcc --- /dev/null +++ b/src/AcDream.App/Studio/LayoutSource.cs @@ -0,0 +1,109 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Studio; + +/// Which kind of source the studio is currently previewing. +public enum LayoutSourceKind { DatLayout, Markup } + +/// +/// Wraps the two ways the UI Studio can load a panel to preview: +/// a LayoutDesc dat id, or a KSML markup file path (Task 6 — unsupported now). +/// +/// Call with the current to +/// import the layout and get the root . The result is also +/// cached in so can re-run the same +/// source without re-reading the options. +/// +public sealed class LayoutSource +{ + private readonly DatCollection _dats; + private readonly Func _resolve; + private readonly UiDatFont? _datFont; + + public LayoutSourceKind Kind { get; private set; } + public uint? LayoutId { get; private set; } + public string? MarkupPath { get; private set; } + public string? LastError { get; private set; } + public ImportedLayout? CurrentLayout { get; private set; } + + public LayoutSource( + DatCollection dats, + Func resolve, + UiDatFont? datFont) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _datFont = datFont; + } + + /// + /// Load the layout described by . For a dat layout + /// ( is non-null) calls + /// . For a markup path sets + /// and returns null (Task 6, not yet implemented). + /// + /// Returns the root on success, or null on failure + /// (check ). + /// + public UiElement? Load(StudioOptions opts) + { + LastError = null; + CurrentLayout = null; + + if (opts.MarkupPath is not null) + { + Kind = LayoutSourceKind.Markup; + MarkupPath = opts.MarkupPath; + LastError = "markup unsupported (Task 6)"; + return null; + } + + if (opts.LayoutId is null) + { + LastError = "ui-studio: no layout id or markup path specified."; + return null; + } + + Kind = LayoutSourceKind.DatLayout; + LayoutId = opts.LayoutId; + return LoadDat(opts.LayoutId.Value); + } + + /// Re-run the most-recently-configured source without re-reading options. + public UiElement? Reload() + { + LastError = null; + CurrentLayout = null; + + if (Kind == LayoutSourceKind.Markup) + { + LastError = "markup unsupported (Task 6)"; + return null; + } + + if (LayoutId is null) + { + LastError = "ui-studio: no layout id to reload."; + return null; + } + + return LoadDat(LayoutId.Value); + } + + // ── Private ────────────────────────────────────────────────────────────────── + + private UiElement? LoadDat(uint layoutId) + { + var imported = LayoutImporter.Import(_dats, layoutId, _resolve, _datFont); + if (imported is null) + { + LastError = $"ui-studio: LayoutDesc 0x{layoutId:X8} not found in dats."; + return null; + } + + CurrentLayout = imported; + return imported.Root; + } +} diff --git a/src/AcDream.App/Studio/StudioOptions.cs b/src/AcDream.App/Studio/StudioOptions.cs new file mode 100644 index 00000000..bd42bd06 --- /dev/null +++ b/src/AcDream.App/Studio/StudioOptions.cs @@ -0,0 +1,60 @@ +namespace AcDream.App.Studio; + +/// +/// Parsed options for the acdream UI Studio standalone tool. +/// Constructed by from the command-line tokens that follow +/// the ui-studio dispatch token. +/// +public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath) +{ + /// + /// Parse studio options from the args that come AFTER the ui-studio token. + /// + /// Positional (first non-flag arg): dat directory. Falls back to + /// ACDREAM_DAT_DIR when omitted. + /// --layout 0xNNNN: hex LayoutDesc dat id to preview. + /// --markup <path>: path to a KSML markup file (Task 6, unsupported for now). + /// When neither --layout nor --markup is given the default layout + /// 0x2100006C (vitals) is used. + /// + public static StudioOptions Parse(string[] args) + { + string? datDir = null; + uint? layoutId = null; + string? markupPath = null; + + for (int i = 0; i < args.Length; i++) + { + if (args[i] == "--layout" && i + 1 < args.Length) + { + var raw = args[++i]; + // Accept 0xNNNN or plain hex. + if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + raw = raw[2..]; + if (uint.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out var id)) + layoutId = id; + } + else if (args[i] == "--markup" && i + 1 < args.Length) + { + markupPath = args[++i]; + } + else if (!args[i].StartsWith('-')) + { + datDir ??= args[i]; + } + } + + // Fall back to ACDREAM_DAT_DIR when no positional dat dir was given. + datDir ??= Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + + if (string.IsNullOrWhiteSpace(datDir)) + throw new InvalidOperationException( + "ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR."); + + // Default layout: vitals (0x2100006C). + if (layoutId is null && markupPath is null) + layoutId = 0x2100006Cu; + + return new StudioOptions(datDir, layoutId, markupPath); + } +} diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs new file mode 100644 index 00000000..d55064b4 --- /dev/null +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -0,0 +1,150 @@ +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Silk.NET.Input; +using Silk.NET.Maths; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; +using static Silk.NET.OpenGL.ClearBufferMask; + +namespace AcDream.App.Studio; + +/// +/// Standalone Silk.NET window that boots the production render stack +/// () and previews a single UI panel +/// identified by a . +/// +/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] +/// +/// The window is intentionally thin: no game world, no physics, no streaming — +/// just GL + UiHost + the layout under test, identical to how the panel +/// appears inside GameWindow. +/// +public sealed class StudioWindow : IDisposable +{ + private readonly StudioOptions _opts; + + // Created in OnLoad, released in OnClosing. + private IWindow? _window; + private DatCollection? _dats; + private RenderStack? _stack; + private LayoutSource? _source; + + public StudioWindow(StudioOptions opts) + { + _opts = opts ?? throw new ArgumentNullException(nameof(opts)); + } + + /// + /// Open the window and block until it is closed. + /// Mirrors GameWindow.Run(). + /// + public void Run() + { + // Resolve quality settings the same way GameWindow.Run() does + // (SettingsStore → QualitySettings.From → WithEnvOverrides). + var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var startupDisplay = startupStore.LoadDisplay(); + var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); + var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + + var options = WindowOptions.Default with + { + Size = new Vector2D(1280, 720), + Title = "acdream UI Studio", + API = new GraphicsAPI( + ContextAPI.OpenGL, + ContextProfile.Core, + ContextFlags.ForwardCompatible, + new APIVersion(4, 3)), + VSync = false, + // MSAA from quality preset — must be baked into the GL context at creation. + Samples = startupQuality.MsaaSamples, + PreferredStencilBufferBits = 8, + }; + + _window = Window.Create(options); + _window.Load += OnLoad; + _window.Update += OnUpdate; + _window.Render += OnRender; + _window.Closing += OnClosing; + _window.Run(); + } + + private void OnLoad() + { + var gl = GL.GetApi(_window!); + var input = _window!.CreateInput(); + + _dats = new DatCollection(_opts.DatDir, DatAccessType.Read); + + // Build QualitySettings for RenderBootstrap (same as Run() above — re-read + // after the GL context is confirmed, mirroring GameWindow.OnLoad). + var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); + var display = store.LoadDisplay(); + var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides( + AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality)); + + _stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality)); + + // Wire input into UiHost. + foreach (var mouse in input.Mice) + _stack.UiHost.WireMouse(mouse); + foreach (var kb in input.Keyboards) + _stack.UiHost.WireKeyboard(kb); + + // Load the panel described by options and add it to the UI tree. + _source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont); + var root = _source.Load(_opts); + if (root is not null) + _stack.UiHost.Root.AddChild(root); + else + Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}"); + } + + private double _dt; + + private void OnUpdate(double dt) + { + _dt = dt; + } + + private void OnRender(double dt) + { + if (_stack is null) return; + + var gl = _stack.Gl; + gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); + gl.Clear(ColorBufferBit | DepthBufferBit); + + _stack.UiHost.Tick(_dt); + _stack.UiHost.Draw(new Vector2(_window!.Size.X, _window!.Size.Y)); + } + + private void OnClosing() + { + _stack?.DrawDispatcher.Dispose(); + _stack?.MeshAdapter.Dispose(); + _stack?.TextureCache.Dispose(); + _stack?.MeshShader.Dispose(); + _stack?.LightingUbo.Dispose(); + _stack?.UiHost.Dispose(); + _dats?.Dispose(); + _dats = null; + _stack = null; + } + + public void Dispose() + { + _window?.Dispose(); + _window = null; + // If OnClosing wasn't called (e.g. exception before Run()), clean up anyway. + _stack?.UiHost.Dispose(); + _dats?.Dispose(); + _dats = null; + _stack = null; + } +} diff --git a/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs b/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs new file mode 100644 index 00000000..5d478223 --- /dev/null +++ b/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs @@ -0,0 +1,60 @@ +using AcDream.App.Studio; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.Studio; + +/// +/// Unit tests for . The dat-backed test skips cleanly +/// when the real dats are not present (CI / dev machines without AC installed). +/// +public class LayoutSourceTests +{ + private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1); + + /// Resolve the client dat directory, or null if unavailable (skip the test). + private static string? ResolveDatDir() + { + var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + var def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + /// + /// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify + /// that LayoutSource returns a non-null root with the layout id findable. + /// Skips when the dats are unavailable. + /// + /// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat + /// id, not an element id in the widget tree. The actual vitals root element id + /// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert + /// FindElement(0x100005F9) here which verifies the same integration path: the + /// layout was successfully loaded and the element dict was populated. + /// + [Fact] + public void LoadsDatLayout_byId() + { + var dir = ResolveDatDir(); + if (dir is null) + return; // Skip: dats not available on this machine / CI. + + using var dats = new DatCollection(dir, DatAccessType.Read); + + var src = new LayoutSource(dats, NoTex, datFont: null); + var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null)); + + // root must be non-null: Import found the LayoutDesc and built the tree. + Assert.NotNull(root); + Assert.NotNull(src.CurrentLayout); + // The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id). + // Asserting FindElement verifies the byId dict was populated by the importer. + Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u)); + Assert.Equal(LayoutSourceKind.DatLayout, src.Kind); + } +}