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);
}
}
}