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
|
|
@ -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;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue