using Silk.NET.OpenGL; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; namespace AcDream.App.Diagnostics; /// /// Render-thread owner for diagnostic captures of the complete default /// framebuffer. Requests may be made while retained UI ticks; capture occurs /// later in the same frame after world, retained UI, and optional ImGui draw. /// internal sealed class FrameScreenshotController { private enum CaptureState { Pending, Complete, Failed, } private sealed record CaptureStatus(CaptureState State, string? Error = null); private readonly Func _readRgba; private readonly string _directory; private readonly Action _log; private readonly Queue _pending = new(); private readonly Dictionary _status = new(StringComparer.OrdinalIgnoreCase); public FrameScreenshotController( GL gl, string directory, Action? log = null) : this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log) { ArgumentNullException.ThrowIfNull(gl); } internal FrameScreenshotController( Func readRgba, string directory, Action? log = null) { _readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba)); _directory = string.IsNullOrWhiteSpace(directory) ? throw new ArgumentException("A screenshot directory is required.", nameof(directory)) : Path.GetFullPath(directory); _log = log ?? (_ => { }); } public bool TryRequest(string name, out string error) { if (!AutomationArtifactName.TryValidate(name, out error)) return false; if (_status.TryGetValue(name, out CaptureStatus? status)) { if (status.State != CaptureState.Failed) return true; error = status.Error ?? $"screenshot '{name}' failed"; return false; } _status.Add(name, new CaptureStatus(CaptureState.Pending)); _pending.Enqueue(name); _log($"[world-gate] screenshot-request name={name}"); return true; } public bool IsComplete(string name) => _status.TryGetValue(name, out CaptureStatus? status) && status.State == CaptureState.Complete; /// Captures at most one queued image on the current GL thread. public bool CapturePending(int width, int height) { if (_pending.Count == 0) return false; string name = _pending.Dequeue(); try { if (width <= 0 || height <= 0) throw new InvalidOperationException($"invalid framebuffer size {width}x{height}"); byte[] pixels = _readRgba(width, height); int expected = checked(width * height * 4); if (pixels.Length != expected) throw new InvalidOperationException( $"framebuffer read returned {pixels.Length} bytes; expected {expected}"); byte[] flipped = FlipRows(pixels, width, height); Directory.CreateDirectory(_directory); string path = Path.Combine(_directory, name + ".png"); string temporaryPath = path + ".tmp"; using (Image image = Image.LoadPixelData(flipped, width, height)) image.SaveAsPng(temporaryPath); File.Move(temporaryPath, path, overwrite: true); _status[name] = new CaptureStatus(CaptureState.Complete); _log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}"); return true; } catch (Exception exception) { string message = $"screenshot '{name}' failed: {exception.Message}"; _status[name] = new CaptureStatus(CaptureState.Failed, message); _log($"[world-gate] screenshot-failed name={name} error={exception.Message}"); return false; } } internal static byte[] FlipRows(byte[] pixels, int width, int height) { int stride = checked(width * 4); byte[] flipped = new byte[pixels.Length]; for (int row = 0; row < height; row++) { System.Buffer.BlockCopy( pixels, row * stride, flipped, (height - 1 - row) * stride, stride); } return flipped; } private static unsafe byte[] ReadDefaultFramebuffer(GL gl, int width, int height) { byte[] pixels = new byte[checked(width * height * 4)]; fixed (byte* pointer = pixels) { gl.ReadPixels( 0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, pointer); } return pixels; } }