feat(studio): StudioWindow + LayoutSource — render a dat panel standalone

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 <path>; 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 14:26:47 +02:00
parent 79ee3ffbe2
commit df6b5b3b39
5 changed files with 387 additions and 0 deletions

View file

@ -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()

View file

@ -0,0 +1,109 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
namespace AcDream.App.Studio;
/// <summary>Which kind of source the studio is currently previewing.</summary>
public enum LayoutSourceKind { DatLayout, Markup }
/// <summary>
/// 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).
///
/// <para>Call <see cref="Load"/> with the current <see cref="StudioOptions"/> to
/// import the layout and get the root <see cref="UiElement"/>. The result is also
/// cached in <see cref="CurrentLayout"/> so <see cref="Reload"/> can re-run the same
/// source without re-reading the options.</para>
/// </summary>
public sealed class LayoutSource
{
private readonly DatCollection _dats;
private readonly Func<uint, (uint, int, int)> _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<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_datFont = datFont;
}
/// <summary>
/// Load the layout described by <paramref name="opts"/>. For a dat layout
/// (<see cref="StudioOptions.LayoutId"/> is non-null) calls
/// <see cref="LayoutImporter.Import"/>. For a markup path sets
/// <see cref="LastError"/> and returns null (Task 6, not yet implemented).
///
/// Returns the root <see cref="UiElement"/> on success, or null on failure
/// (check <see cref="LastError"/>).
/// </summary>
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);
}
/// <summary>Re-run the most-recently-configured source without re-reading options.</summary>
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;
}
}

View file

@ -0,0 +1,60 @@
namespace AcDream.App.Studio;
/// <summary>
/// Parsed options for the acdream UI Studio standalone tool.
/// Constructed by <see cref="Parse"/> from the command-line tokens that follow
/// the <c>ui-studio</c> dispatch token.
/// </summary>
public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath)
{
/// <summary>
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
///
/// <para>Positional (first non-flag arg): dat directory. Falls back to
/// <c>ACDREAM_DAT_DIR</c> when omitted.</para>
/// <para><c>--layout 0xNNNN</c>: hex LayoutDesc dat id to preview.</para>
/// <para><c>--markup &lt;path&gt;</c>: path to a KSML markup file (Task 6, unsupported for now).</para>
/// <para>When neither <c>--layout</c> nor <c>--markup</c> is given the default layout
/// <c>0x2100006C</c> (vitals) is used.</para>
/// </summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Standalone Silk.NET window that boots the production render stack
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
/// identified by a <see cref="LayoutSource"/>.
///
/// 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 <c>GameWindow</c>.
/// </summary>
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));
}
/// <summary>
/// Open the window and block until it is closed.
/// Mirrors <c>GameWindow.Run()</c>.
/// </summary>
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<int>(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;
}
}

View file

@ -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;
/// <summary>
/// Unit tests for <see cref="LayoutSource"/>. The dat-backed test skips cleanly
/// when the real dats are not present (CI / dev machines without AC installed).
/// </summary>
public class LayoutSourceTests
{
private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1);
/// <summary>Resolve the client dat directory, or null if unavailable (skip the test).</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}