Move the accepted draw transaction and failure recovery behind typed frame-phase owners so GameWindow only supplies immutable frame input. Preserve retail draw order, make ImGui/bootstrap shutdown ownership explicit, and restore exact text-render GL state on failures.\n\nCo-authored-by: Codex <codex@openai.com>
145 lines
4.9 KiB
C#
145 lines
4.9 KiB
C#
using Silk.NET.OpenGL;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace AcDream.App.Diagnostics;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal sealed class FrameScreenshotController
|
|
{
|
|
private enum CaptureState
|
|
{
|
|
Pending,
|
|
Complete,
|
|
Failed,
|
|
}
|
|
|
|
private sealed record CaptureStatus(CaptureState State, string? Error = null);
|
|
|
|
private readonly Func<int, int, byte[]> _readRgba;
|
|
private readonly string _directory;
|
|
private readonly Action<string> _log;
|
|
private readonly Queue<string> _pending = new();
|
|
private readonly Dictionary<string, CaptureStatus> _status =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public FrameScreenshotController(
|
|
GL gl,
|
|
string directory,
|
|
Action<string>? log = null)
|
|
: this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(gl);
|
|
}
|
|
|
|
internal FrameScreenshotController(
|
|
Func<int, int, byte[]> readRgba,
|
|
string directory,
|
|
Action<string>? 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;
|
|
|
|
/// <summary>Captures at most one queued image on the current GL thread.</summary>
|
|
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<Rgba32> image = Image.LoadPixelData<Rgba32>(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;
|
|
}
|
|
}
|