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:
parent
79ee3ffbe2
commit
df6b5b3b39
5 changed files with 387 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
109
src/AcDream.App/Studio/LayoutSource.cs
Normal file
109
src/AcDream.App/Studio/LayoutSource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
src/AcDream.App/Studio/StudioOptions.cs
Normal file
60
src/AcDream.App/Studio/StudioOptions.cs
Normal 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 <path></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);
|
||||
}
|
||||
}
|
||||
150
src/AcDream.App/Studio/StudioWindow.cs
Normal file
150
src/AcDream.App/Studio/StudioWindow.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs
Normal file
60
tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue