test(runtime): add deterministic world gate artifacts

This commit is contained in:
Erik 2026-07-20 22:37:16 +02:00
parent db45b81f75
commit 354c2adc2e
11 changed files with 703 additions and 10 deletions

View file

@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable
private bool _wbDiagNoticePrinted;
private bool _wasEnabled;
/// <summary>Most recent immutable report line, for explicit automation checkpoints.</summary>
public string? LastReport { get; private set; }
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable
int gc0 = GC.CollectionCount(0) - _gc0Base;
int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base;
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs);
Console.WriteLine(LastReport);
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0;

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

View file

@ -0,0 +1,155 @@
using System.Text.Json;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
namespace AcDream.App.Diagnostics;
internal static class AutomationArtifactName
{
public static bool TryValidate(string? name, out string error)
{
if (string.IsNullOrWhiteSpace(name) || name.Length > 80)
{
error = "artifact name must contain 1-80 characters";
return false;
}
foreach (char c in name)
{
if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_')
{
error = $"artifact name '{name}' may contain only letters, digits, '-' and '_'";
return false;
}
}
error = string.Empty;
return true;
}
}
internal sealed record WorldLifecycleResourceSnapshot(
int LoadedLandblocks,
int WorldEntities,
int AnimatedEntities,
int VisibleLandblocks,
int TotalLandblocks,
int LiveEntities,
int MaterializedLiveEntities,
int PendingLiveTeardowns,
int PendingLandblockRetirements,
int ParticleEmitters,
int Particles,
int ParticleBindings,
int ParticleOwners,
int EffectOwners,
int LightOwners,
int ScriptOwners,
int ActiveScripts,
int MeshRenderData,
int MeshAtlasArrays,
long MeshEstimatedBytes,
int StagedMeshUploads,
long StagedMeshBytes,
long TrackedGpuBytes,
int TrackedGpuBuffers,
int TrackedGpuTextures,
int OwnedCompositeTextures,
int CompositeTextureOwners,
int ActiveParticleTextures,
int ParticleTextureOwners,
int CompositeWarmupPending,
long ManagedBytes,
long ManagedCommittedBytes,
double Fps,
double FrameMilliseconds,
string? LastFrameProfile);
internal sealed record WorldLifecycleCheckpoint(
int Sequence,
string Name,
DateTime TimestampUtc,
int ProcessId,
WorldRevealLifecycleSnapshot Reveal,
WorldLifecycleResourceSnapshot Resources);
/// <summary>
/// Diagnostic-only runtime seam used by production retained-UI scripts. It
/// writes one structured checkpoint at explicit script edges and delegates GL
/// capture to the render-thread screenshot owner.
/// </summary>
internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly Func<WorldRevealLifecycleSnapshot> _getReveal;
private readonly Func<int> _getPortalMaterializationCount;
private readonly Func<WorldLifecycleResourceSnapshot> _captureResources;
private readonly FrameScreenshotController _screenshots;
private readonly string _artifactDirectory;
private readonly Action<string> _log;
private int _sequence;
public WorldLifecycleAutomationController(
Func<WorldRevealLifecycleSnapshot> getReveal,
Func<int> getPortalMaterializationCount,
Func<WorldLifecycleResourceSnapshot> captureResources,
FrameScreenshotController screenshots,
string artifactDirectory,
Action<string>? log = null)
{
_getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal));
_getPortalMaterializationCount = getPortalMaterializationCount
?? throw new ArgumentNullException(nameof(getPortalMaterializationCount));
_captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources));
_screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots));
_artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory)
? throw new ArgumentException("An automation artifact directory is required.", nameof(artifactDirectory))
: Path.GetFullPath(artifactDirectory);
_log = log ?? (_ => { });
}
public bool IsWorldReady => _getReveal().IsReady;
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
public int PortalMaterializationCount => _getPortalMaterializationCount();
public bool TryWriteCheckpoint(string name, out string error)
{
if (!AutomationArtifactName.TryValidate(name, out error))
return false;
try
{
Directory.CreateDirectory(_artifactDirectory);
var checkpoint = new WorldLifecycleCheckpoint(
Sequence: checked(++_sequence),
Name: name,
TimestampUtc: DateTime.UtcNow,
ProcessId: Environment.ProcessId,
Reveal: _getReveal(),
Resources: _captureResources());
string json = JsonSerializer.Serialize(checkpoint, JsonOptions);
string timelinePath = Path.Combine(_artifactDirectory, "world-lifecycle.checkpoints.jsonl");
File.AppendAllText(timelinePath, json + Environment.NewLine);
File.WriteAllText(
Path.Combine(_artifactDirectory, $"checkpoint-{name}.json"),
json + Environment.NewLine);
_log($"[world-gate] checkpoint name={name} sequence={checkpoint.Sequence} path={timelinePath}");
error = string.Empty;
return true;
}
catch (Exception exception)
{
error = $"checkpoint '{name}' failed: {exception.Message}";
return false;
}
}
public bool TryRequestScreenshot(string name, out string error) =>
_screenshots.TryRequest(name, out error);
public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name);
}