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

@ -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;
}
}