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