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