acdream/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Erik 9d7df1bfc5 refactor(render): compose render frame orchestrator
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>
2026-07-22 08:03:49 +02:00

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