test(runtime): add deterministic world gate artifacts
This commit is contained in:
parent
db45b81f75
commit
354c2adc2e
11 changed files with 703 additions and 10 deletions
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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 Width, int Height)> _getSize;
|
||||
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,
|
||||
Func<(int Width, int Height)> getSize,
|
||||
string directory,
|
||||
Action<string>? log = null)
|
||||
: this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
}
|
||||
|
||||
internal FrameScreenshotController(
|
||||
Func<(int Width, int Height)> getSize,
|
||||
Func<int, int, byte[]> readRgba,
|
||||
string directory,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_getSize = getSize ?? throw new ArgumentNullException(nameof(getSize));
|
||||
_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 void CapturePending()
|
||||
{
|
||||
if (_pending.Count == 0)
|
||||
return;
|
||||
|
||||
string name = _pending.Dequeue();
|
||||
try
|
||||
{
|
||||
(int width, int height) = _getSize();
|
||||
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}");
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue