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); 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; } /// /// The GL surface state a default-framebuffer capture touches, isolated so /// the bind/read/restore order is assertable without a GL context. /// internal interface IDefaultFramebufferSurface { /// The name currently bound to GL_READ_FRAMEBUFFER. uint ReadFramebufferBinding { get; } /// The name currently bound to GL_DRAW_FRAMEBUFFER. uint DrawFramebufferBinding { get; } /// /// GL_SAMPLES for the default framebuffer. Queried with /// framebuffer 0 bound to both targets, because the value is /// framebuffer-dependent state and would otherwise report whichever /// offscreen target the frame left bound. /// int DefaultFramebufferSamples { get; } void BindReadFramebuffer(uint framebuffer); void BindDrawFramebuffer(uint framebuffer); /// /// Creates a single-sampled RGBA8 colour framebuffer of the given size /// and returns its name. /// uint CreateResolveTarget(int width, int height); void DeleteResolveTarget(uint framebuffer); /// /// Blits the whole colour buffer from the bound read framebuffer to the /// bound draw framebuffer with GL_NEAREST and identical rectangles /// — the multisample resolve. /// void BlitColorNearest(int width, int height); void ReadRgba(int width, int height, byte[] destination); } /// /// Reads the default framebuffer — framebuffer name 0, the backbuffer — /// and nothing else. /// /// /// /// glReadPixels reads whatever is bound to GL_READ_FRAMEBUFFER, /// so a capture that does not name its source silently captures whichever /// offscreen target the previous renderer left bound. The frame this runs in /// draws several: PrivateEntityViewportRenderer clears its paperdoll /// and appraisal FBOs to exactly RGBA(0,0,0,0), which is what a leaked /// binding writes to disk — a fully transparent PNG that reads as a /// blank-world failure while the backbuffer on screen was correct. /// /// /// This was latent for as long as something else rebound framebuffer 0 often /// enough to mask it (before Campaign V slice V4c, GL BeginPass did so /// on every pass — see plan §5.4). The capture states its own source instead /// of inheriting one, and restores the caller's binding so a diagnostic /// capture cannot perturb the frame it observes. /// /// /// Multisampling. The window is created with the quality preset's /// MSAA sample count, so the default framebuffer is normally 4x multisampled, /// and glReadPixels against a multisampled read framebuffer is /// undefined per the GL spec (GL 4.6 §18.2: an INVALID_OPERATION is /// generated only for framebuffer objects; for the default framebuffer the /// result is simply unspecified, and AMD returns real pixels most of the time /// and something else the rest). Every automated pixel gate and every blank- /// world verdict in Campaign V reads through here, so an unspecified read is /// an unsound instrument, not a cosmetic issue. When the default framebuffer /// is multisampled the capture resolves it first — blit the whole colour /// buffer into a single-sampled RGBA8 framebuffer with identical rectangles /// and GL_NEAREST, which is the defined resolve — and reads that. /// A single-sampled default framebuffer keeps the original direct read, so /// non-MSAA captures stay byte-for-byte what they were. /// /// /// The resolve target is created and destroyed per capture rather than /// cached: captures are rare (a handful per gate run), and a cache would have /// to track window resizes and context teardown for no measurable gain. /// /// internal static byte[] ReadDefaultFramebuffer( IDefaultFramebufferSurface surface, int width, int height) { ArgumentNullException.ThrowIfNull(surface); byte[] pixels = new byte[checked(width * height * 4)]; uint previousRead = surface.ReadFramebufferBinding; uint previousDraw = surface.DrawFramebufferBinding; surface.BindReadFramebuffer(0u); surface.BindDrawFramebuffer(0u); try { if (surface.DefaultFramebufferSamples > 1) ResolveThenRead(surface, width, height, pixels); else surface.ReadRgba(width, height, pixels); } finally { surface.BindReadFramebuffer(previousRead); surface.BindDrawFramebuffer(previousDraw); } return pixels; } private static void ResolveThenRead( IDefaultFramebufferSurface surface, int width, int height, byte[] pixels) { uint resolve = surface.CreateResolveTarget(width, height); try { // Read is still framebuffer 0 — the multisampled source. surface.BindDrawFramebuffer(resolve); surface.BlitColorNearest(width, height); surface.BindReadFramebuffer(resolve); surface.ReadRgba(width, height, pixels); } finally { surface.DeleteResolveTarget(resolve); } } }