diff --git a/src/AcDream.App/Studio/PanelFbo.cs b/src/AcDream.App/Studio/PanelFbo.cs
index de6aabb4..4f61ec55 100644
--- a/src/AcDream.App/Studio/PanelFbo.cs
+++ b/src/AcDream.App/Studio/PanelFbo.cs
@@ -67,6 +67,34 @@ public sealed unsafe class PanelFbo : IDisposable
return _colorTex;
}
+ ///
+ /// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
+ /// Must be called AFTER for the same and
+ /// (so the FBO exists and is the right size).
+ ///
+ /// FBO origin is bottom-left (GL convention). The caller is responsible for
+ /// flipping rows vertically before saving as a top-left-origin image format (PNG).
+ ///
+ /// Returns an empty array when the FBO is not ready.
+ ///
+ public unsafe byte[] ReadColorRgba(int width, int height)
+ {
+ if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty();
+
+ int byteCount = width * height * 4;
+ var buf = new byte[byteCount];
+
+ _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
+ fixed (byte* p = buf)
+ {
+ _gl.ReadPixels(0, 0, (uint)width, (uint)height,
+ PixelFormat.Rgba, PixelType.UnsignedByte, p);
+ }
+ _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
+
+ return buf;
+ }
+
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
private void EnsureFramebuffer(int width, int height)
diff --git a/src/AcDream.App/Studio/StudioOptions.cs b/src/AcDream.App/Studio/StudioOptions.cs
index 6a6b53b8..2a715b01 100644
--- a/src/AcDream.App/Studio/StudioOptions.cs
+++ b/src/AcDream.App/Studio/StudioOptions.cs
@@ -9,8 +9,9 @@ public sealed record StudioOptions(
string DatDir,
uint? LayoutId,
string? MarkupPath,
- string? DumpSlug = null,
- string? DumpFile = null)
+ string? DumpSlug = null,
+ string? DumpFile = null,
+ string? ScreenshotPath = null)
{
///
/// Parse studio options from the args that come AFTER the ui-studio token.
@@ -24,16 +25,20 @@ public sealed record StudioOptions(
/// --dump-file <path>: override the default dump file path
/// (docs/research/2026-06-25-retail-ui-layout-dump.json from the solution root).
/// Only meaningful when --dump is also given.
+ /// --screenshot <path>: headless mode — render the loaded panel to a PNG
+ /// at and exit without showing an interactive window.
+ /// Combines with --dump or --layout.
/// When neither --layout, --markup, nor --dump is given the
/// default layout 0x2100006C (vitals) is used.
///
public static StudioOptions Parse(string[] args)
{
- string? datDir = null;
- uint? layoutId = null;
- string? markupPath = null;
- string? dumpSlug = null;
- string? dumpFile = null;
+ string? datDir = null;
+ uint? layoutId = null;
+ string? markupPath = null;
+ string? dumpSlug = null;
+ string? dumpFile = null;
+ string? screenshotPath = null;
for (int i = 0; i < args.Length; i++)
{
@@ -58,6 +63,10 @@ public sealed record StudioOptions(
{
dumpFile = args[++i];
}
+ else if (args[i] == "--screenshot" && i + 1 < args.Length)
+ {
+ screenshotPath = args[++i];
+ }
else if (!args[i].StartsWith('-'))
{
datDir ??= args[i];
@@ -75,7 +84,7 @@ public sealed record StudioOptions(
if (layoutId is null && markupPath is null && dumpSlug is null)
layoutId = 0x2100006Cu;
- return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile);
+ return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath);
}
///
diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs
index 67438631..64ab906f 100644
--- a/src/AcDream.App/Studio/StudioWindow.cs
+++ b/src/AcDream.App/Studio/StudioWindow.cs
@@ -7,6 +7,8 @@ using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio;
@@ -51,6 +53,10 @@ public sealed class StudioWindow : IDisposable
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
+ // Headless screenshot mode: set when --screenshot was passed.
+ // True after the first OnRender fires the screenshot (guard against repeat).
+ private bool _screenshotDone;
+
public StudioWindow(StudioOptions opts)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
@@ -83,6 +89,10 @@ public sealed class StudioWindow : IDisposable
// MSAA from quality preset — must be baked into the GL context at creation.
Samples = startupQuality.MsaaSamples,
PreferredStencilBufferBits = 8,
+ // Headless screenshot mode: hide the window so no desktop flash occurs.
+ // The GL context is still fully valid on a hidden window; FBO rendering
+ // is off-screen and independent of window visibility.
+ IsVisible = _opts.ScreenshotPath is null,
};
_window = Window.Create(options);
@@ -96,7 +106,6 @@ public sealed class StudioWindow : IDisposable
private void OnLoad()
{
var gl = GL.GetApi(_window!);
- var input = _window!.CreateInput();
_dats = new DatCollection(_opts.DatDir, DatAccessType.Read);
@@ -110,12 +119,6 @@ public sealed class StudioWindow : IDisposable
_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.
// Task 4b: --dump uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
@@ -161,22 +164,91 @@ public sealed class StudioWindow : IDisposable
}
}
- // Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed.
- _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
- _panelFbo = new PanelFbo(gl);
- _inspector = new StudioInspector();
+ // Task 3: ImGui IDE — interactive mode only.
+ // Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
+ _panelFbo = new PanelFbo(gl);
+ if (_opts.ScreenshotPath is null)
+ {
+ var input = _window!.CreateInput();
+ // Wire input into UiHost (interactive only).
+ foreach (var mouse in input.Mice)
+ _stack.UiHost.WireMouse(mouse);
+ foreach (var kb in input.Keyboards)
+ _stack.UiHost.WireKeyboard(kb);
+ _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
+ _inspector = new StudioInspector();
+ }
}
private void OnUpdate(double dt) { }
private void OnRender(double dt)
{
- if (_stack is null || _imgui is null || _panelFbo is null || _inspector is null)
+ if (_stack is null || _panelFbo is null) return;
+
+ // ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
+ if (_opts.ScreenshotPath is not null)
+ {
+ if (_screenshotDone) return; // fire exactly once
+ _screenshotDone = true;
+
+ // Pick render size from the loaded root's bounds (clamped to sane limits).
+ // Fall back to 1280×720 when the root has no explicit size.
+ int w = 1280, h = 720;
+ if (_panelRoot is not null)
+ {
+ float rw = _panelRoot.Width;
+ float rh = _panelRoot.Height;
+ if (rw >= 1f && rh >= 1f)
+ {
+ w = Math.Clamp((int)rw, 256, 2048);
+ h = Math.Clamp((int)rh, 256, 2048);
+ }
+ }
+
+ // Tick once so widget state is initialised (e.g. bar fills).
+ _stack.UiHost.Tick(dt);
+
+ // Render the panel into the FBO.
+ _panelFbo.Render(w, h, _stack.UiHost);
+
+ // Read back RGBA pixels (FBO origin = bottom-left).
+ byte[] pixels = _panelFbo.ReadColorRgba(w, h);
+ if (pixels.Length == 0)
+ {
+ Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
+ _window?.Close();
+ return;
+ }
+
+ // Flip rows vertically: FBO bottom-left → PNG top-left.
+ int stride = w * 4;
+ byte[] flipped = new byte[pixels.Length];
+ for (int row = 0; row < h; row++)
+ {
+ System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
+ }
+
+ // Build ImageSharp image and save as PNG.
+ var path = _opts.ScreenshotPath;
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ using var img = Image.LoadPixelData(flipped, w, h);
+ img.SaveAsPng(path);
+ Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
+
+ _window?.Close();
return;
+ }
+
+ // ── INTERACTIVE PATH ──────────────────────────────────────────────────────
+ if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl;
- int w = _window!.Size.X;
- int h = _window!.Size.Y;
+ int iw = _window!.Size.X;
+ int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_stack.UiHost.Tick(dt);
@@ -184,7 +256,7 @@ public sealed class StudioWindow : IDisposable
// 2. Render the panel into the off-screen FBO; get the color texture.
// The FBO is the same logical size as the window, so element rects map 1:1 to
// FBO pixels — no scale factor needed when displaying the canvas at full size.
- uint panelTex = _panelFbo.Render(w, h, _stack.UiHost);
+ uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
@@ -196,7 +268,7 @@ public sealed class StudioWindow : IDisposable
// 5. Canvas pane — show the FBO texture; detect clicks.
(int x, int y)? click = null;
if (panelTex != 0)
- click = _inspector.DrawCanvas((nint)panelTex, w, h);
+ click = _inspector.DrawCanvas((nint)panelTex, iw, ih);
// 6. If the user clicked inside the canvas, hit-test the whole UI tree (UiRoot.Pick honors
// Z-order + modal exclusivity) and select the topmost element. The canvas click coord is