perf(diag): complete trustworthy Slice A capture tooling

Correct whole-frame GPU timestamps so they bracket only the accepted render transaction and associate delayed query results with the owning CPU frame. Add route-wide frame-history summaries, fixed-camera screenshot comparison, process counters, contention traces, a pinned Arwic workload, and credential-safe launch disclosure.

The reference hardware/display contract now keeps local and RDP populations separate and defines the screenshot and re-baseline rules needed by later prepared-content gates.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-24 12:46:51 +02:00
parent bf7ec12f68
commit 3ee8ec537a
23 changed files with 1639 additions and 170 deletions

View file

@ -256,9 +256,7 @@ internal sealed class FrameRootCompositionPhase
interaction.RetainedUi?.Host.TextRenderer,
live.ClipFrame,
foundation.Terrain,
foundation.SceneLighting,
d.FrameProfiler,
d.Gl),
foundation.SceneLighting),
new RuntimeRenderFrameClearPhase(
d.Gl,
d.WorldTime,
@ -454,6 +452,7 @@ internal sealed class FrameRootCompositionPhase
renderWeatherFrame);
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl),
framePreparation,
worldSceneRenderer,
privatePresentation,

View file

@ -32,35 +32,25 @@ public enum FrameStage
/// together. <c>GpuUs</c> is <c>-1</c> for a frame with no available GPU
/// sample (warm-up, or <c>ACDREAM_WB_DIAG=1</c> self-disable).
/// </summary>
internal readonly struct FrameHistoryRecord(
int frameIndex,
double timestampMs,
long cpuUs,
long gpuUs,
long allocBytes,
long updateUs,
long uploadUs,
long imGuiUs,
long pacingUs)
{
public readonly int FrameIndex = frameIndex;
public readonly double TimestampMs = timestampMs;
public readonly long CpuUs = cpuUs;
public readonly long GpuUs = gpuUs;
public readonly long AllocBytes = allocBytes;
public readonly long UpdateUs = updateUs;
public readonly long UploadUs = uploadUs;
public readonly long ImGuiUs = imGuiUs;
public readonly long PacingUs = pacingUs;
}
internal readonly record struct FrameHistoryRecord(
int FrameIndex,
double TimestampMs,
long CpuUs,
long GpuUs,
long AllocBytes,
long UpdateUs,
long UploadUs,
long ImGuiUs,
long PacingUs);
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
/// <c>FrameBoundary</c> call at the top of the accepted render transaction
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// (captures the FULL frame including present) and samples per-frame allocated
/// bytes + GC collection counts. <see cref="BeginGpuFrame"/> and
/// <see cref="EndGpuFrame"/> separately bracket only submitted render work.
/// Stage scopes
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
@ -79,8 +69,9 @@ internal readonly struct FrameHistoryRecord(
/// preallocated, grow-as-needed <see cref="List{T}"/> (1 int + 1 double +
/// 7 longs ≈ 72 bytes/record; a multi-hour capture at 165 fps is roughly
/// 165 * 3600 * 72 bytes ≈ 43 MB/hour — fine for a bounded diagnostic run,
/// not for unattended day-long capture). ZERO frame-thread I/O: the list only grows during
/// <see cref="FrameBoundary"/>; the CSV is written once, from
/// not for unattended day-long capture). Its initial capacity is about 9 MiB
/// and covers the canonical route above 300 FPS without a frame-thread resize.
/// ZERO frame-thread I/O: the CSV is written once, from
/// <see cref="Dispose"/>, at shutdown. Recording only takes effect while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is ALSO true — it
/// reuses that instrumentation rather than duplicating it. Does not
@ -90,7 +81,7 @@ internal readonly struct FrameHistoryRecord(
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const int HistoryInitialCapacity = 4096;
private const int HistoryInitialCapacity = 131072;
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
@ -104,7 +95,8 @@ public sealed class FrameProfiler : IDisposable
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private readonly List<FrameHistoryRecord>? _history;
private readonly long _profilerStartTimestamp;
private int _historyFrameIndex;
private readonly DateTime _profilerStartUtc;
private int _currentFrameIndex = -1;
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
@ -127,12 +119,14 @@ public sealed class FrameProfiler : IDisposable
_stageAccumTicks = new long[StageCount];
_lastStageUs = new long[StageCount];
_profilerStartTimestamp = Stopwatch.GetTimestamp();
_profilerStartUtc = DateTime.UtcNow;
if (RenderingDiagnostics.FrameHistoryPath is not null)
_history = new List<FrameHistoryRecord>(HistoryInitialCapacity);
}
/// <summary>
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
/// Call once at the accepted render-transaction boundary, before
/// <see cref="BeginGpuFrame"/>.
/// </summary>
public void FrameBoundary(GL gl)
{
@ -150,6 +144,7 @@ public sealed class FrameProfiler : IDisposable
_gpuTimer = null;
_wasEnabled = false;
_lastBoundaryTimestamp = 0;
_currentFrameIndex = -1;
}
return;
}
@ -164,8 +159,6 @@ public sealed class FrameProfiler : IDisposable
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
bool historyFrame = false;
long historyCpuUs = 0, historyAllocBytes = 0;
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
@ -179,6 +172,7 @@ public sealed class FrameProfiler : IDisposable
Array.Clear(_stageAccumTicks);
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
_currentFrameIndex = 0;
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
@ -199,32 +193,25 @@ public sealed class FrameProfiler : IDisposable
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
historyFrame = _history is not null;
historyCpuUs = cpuUs;
historyAllocBytes = allocDelta;
if (_history is not null)
{
_history.Add(new FrameHistoryRecord(
_currentFrameIndex,
(now - _profilerStartTimestamp) * 1000.0 / Stopwatch.Frequency,
cpuUs,
-1L,
allocDelta,
_lastStageUs[(int)FrameStage.Update],
_lastStageUs[(int)FrameStage.Upload],
_lastStageUs[(int)FrameStage.ImGui],
_lastStageUs[(int)FrameStage.Pacing]));
}
_currentFrameIndex++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
long? gpuUsSample = _gpuTimer?.FrameBoundary();
if (gpuUsSample is long gpuUs)
_gpuUs.Push(gpuUs);
if (historyFrame)
{
_history!.Add(new FrameHistoryRecord(
_historyFrameIndex++,
(now - _profilerStartTimestamp) * 1000.0 / Stopwatch.Frequency,
historyCpuUs,
gpuUsSample ?? -1L,
historyAllocBytes,
_lastStageUs[(int)FrameStage.Update],
_lastStageUs[(int)FrameStage.Upload],
_lastStageUs[(int)FrameStage.ImGui],
_lastStageUs[(int)FrameStage.Pacing]));
}
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
@ -242,6 +229,38 @@ public sealed class FrameProfiler : IDisposable
}
}
/// <summary>
/// Begin GPU timing immediately before render-resource, world, and private
/// presentation submission. Completed delayed samples are associated with
/// their owning history row rather than the frame that happened to poll
/// them.
/// </summary>
public void BeginGpuFrame()
{
if (!_wasEnabled || _gpuTimer is null || _currentFrameIndex < 0)
return;
Span<GpuFrameSample> completed = stackalloc GpuFrameSample[4];
int completedCount = _gpuTimer.BeginFrame(_currentFrameIndex, completed);
for (int index = 0; index < completedCount; index++)
{
GpuFrameSample sample = completed[index];
_gpuUs.Push(sample.ElapsedUs);
if (_history is not null
&& (uint)sample.FrameIndex < (uint)_history.Count)
{
FrameHistoryRecord row = _history[sample.FrameIndex];
_history[sample.FrameIndex] = row with { GpuUs = sample.ElapsedUs };
}
}
}
/// <summary>End GPU timing immediately after render submission.</summary>
public void EndGpuFrame()
{
_gpuTimer?.EndFrame();
}
/// <summary>
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
@ -283,14 +302,22 @@ public sealed class FrameProfiler : IDisposable
}
/// <summary>Pure CSV formatter — unit-tested; invariant culture. One header row plus one row per record.</summary>
internal static void WriteHistoryCsv(IEnumerable<FrameHistoryRecord> records, TextWriter writer)
internal static void WriteHistoryCsv(
IEnumerable<FrameHistoryRecord> records,
TextWriter writer,
DateTime profilerStartUtc)
{
var ci = CultureInfo.InvariantCulture;
writer.WriteLine("frame,timestamp_ms,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us");
DateTime startUtc = profilerStartUtc.ToUniversalTime();
writer.WriteLine(
"frame,timestamp_ms,timestamp_utc,cpu_us,gpu_us,alloc_bytes,"
+ "update_us,upload_us,imgui_us,pacing_us");
foreach (FrameHistoryRecord r in records)
{
writer.Write(r.FrameIndex.ToString(ci)); writer.Write(',');
writer.Write(r.TimestampMs.ToString("0.000", ci)); writer.Write(',');
writer.Write(startUtc.AddMilliseconds(r.TimestampMs).ToString("O", ci));
writer.Write(',');
writer.Write(r.CpuUs.ToString(ci)); writer.Write(',');
writer.Write(r.GpuUs.ToString(ci)); writer.Write(',');
writer.Write(r.AllocBytes.ToString(ci)); writer.Write(',');
@ -316,7 +343,7 @@ public sealed class FrameProfiler : IDisposable
try
{
using var writer = new StreamWriter(path, append: false);
WriteHistoryCsv(_history, writer);
WriteHistoryCsv(_history, writer, _profilerStartUtc);
Console.WriteLine($"[frame-prof] wrote {_history.Count} history record(s) to '{path}'");
}
catch (Exception ex)

View file

@ -3,18 +3,27 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
internal readonly record struct GpuFrameSample(int FrameIndex, long ElapsedUs);
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// MP0 (2026-07-05) — render-transaction GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// read several frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
/// the pending flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
///
/// <para>The query begins immediately before render-resource/world/private
/// presentation submission and ends immediately after it. It is deliberately
/// NOT left active until the next render callback: doing so includes display
/// pacing and an arbitrary CPU idle interval in the GPU clock. Completed
/// samples retain the frame index that owned the query so the delayed result
/// can be paired with the correct CPU/history row.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
@ -22,9 +31,10 @@ internal sealed class GpuFrameTimer : IDisposable
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
private readonly bool[] _pending = new bool[RingDepth];
private readonly int[] _frameIndices = new int[RingDepth];
private int _nextSlot;
private int _activeSlot = -1;
public GpuFrameTimer(GL gl)
{
@ -34,48 +44,74 @@ internal sealed class GpuFrameTimer : IDisposable
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// Poll every ended query non-blocking, then begin this frame's render
/// transaction in the first free ring slot. If all slots are still in
/// flight the current frame is intentionally left unmeasured rather than
/// stalling or overwriting a pending result.
/// </summary>
public long? FrameBoundary()
public int BeginFrame(int frameIndex, Span<GpuFrameSample> completed)
{
if (_queryActive)
if (_activeSlot >= 0)
throw new InvalidOperationException("GPU frame timing was begun twice without an end.");
if (completed.Length < RingDepth)
throw new ArgumentException(
$"Completed-sample storage must hold at least {RingDepth} entries.",
nameof(completed));
int completedCount = 0;
for (int slot = 0; slot < RingDepth; slot++)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
if (!_pending[slot])
continue;
_gl.GetQueryObject(
_queries[slot],
QueryObjectParameterName.ResultAvailable,
out int available);
if (available == 0)
continue;
_gl.GetQueryObject(
_queries[slot],
QueryObjectParameterName.Result,
out ulong elapsedNanoseconds);
completed[completedCount++] = new GpuFrameSample(
_frameIndices[slot],
(long)(elapsedNanoseconds / 1000UL));
_pending[slot] = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
for (int offset = 0; offset < RingDepth; offset++)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
int slot = (_nextSlot + offset) % RingDepth;
if (_pending[slot])
continue;
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[slot]);
_frameIndices[slot] = frameIndex;
_activeSlot = slot;
_nextSlot = (slot + 1) % RingDepth;
break;
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
return completedCount;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
/// <summary>End the query around the current render transaction.</summary>
public void EndFrame()
{
if (_activeSlot < 0)
return;
_gl.EndQuery(QueryTarget.TimeElapsed);
_pending[_activeSlot] = true;
_activeSlot = -1;
}
/// <summary>End an active query without beginning another.</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
EndFrame();
}
public void Dispose()

View file

@ -0,0 +1,32 @@
using AcDream.App.Diagnostics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Production render-transaction measurement adapter. CPU frame boundaries
/// remain owned by <see cref="FrameProfiler"/> while this adapter places the
/// GPU query around only the GL-producing render phases.
/// </summary>
internal sealed class FrameProfilerGpuMeasurement : IRenderFrameGpuMeasurement
{
private readonly FrameProfiler _profiler;
private readonly GL _gl;
public FrameProfilerGpuMeasurement(FrameProfiler profiler, GL gl)
{
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void BeginFrame()
{
_profiler.FrameBoundary(_gl);
_profiler.BeginGpuFrame();
}
public void EndFrame()
{
_profiler.EndGpuFrame();
}
}

View file

@ -43,6 +43,18 @@ internal interface IRenderFrameResourcePhase
void Prepare(RenderFrameInput input);
}
/// <summary>
/// Diagnostic GPU timer bracketing only render-resource, world, and private
/// presentation submission. It deliberately excludes diagnostics, pacing,
/// and the idle interval before the next render callback.
/// </summary>
internal interface IRenderFrameGpuMeasurement
{
void BeginFrame();
void EndFrame();
}
internal interface IWorldSceneFramePhase
{
WorldRenderFrameOutcome Render(RenderFrameInput input);
@ -117,6 +129,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove
internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
{
private readonly IRenderFrameLifetime _lifetime;
private readonly IRenderFrameGpuMeasurement _gpuMeasurement;
private readonly IRenderFrameResourcePhase _resources;
private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation;
@ -126,6 +139,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
IRenderFrameGpuMeasurement gpuMeasurement,
IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation,
@ -134,6 +148,8 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
IRenderFrameFailureRecovery recovery)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_gpuMeasurement = gpuMeasurement
?? throw new ArgumentNullException(nameof(gpuMeasurement));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_world = world ?? throw new ArgumentNullException(nameof(world));
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
@ -149,10 +165,37 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
Exception? renderFailure = null;
try
{
_resources.Prepare(input);
WorldRenderFrameOutcome world = _world.Render(input);
PrivatePresentationFrameOutcome presentation =
_presentation.Render(input, world);
WorldRenderFrameOutcome world;
PrivatePresentationFrameOutcome presentation;
Exception? measuredRenderFailure = null;
_gpuMeasurement.BeginFrame();
try
{
_resources.Prepare(input);
world = _world.Render(input);
presentation = _presentation.Render(input, world);
}
catch (Exception error)
{
measuredRenderFailure = error;
throw;
}
finally
{
try
{
_gpuMeasurement.EndFrame();
}
catch (Exception measurementFailure)
when (measuredRenderFailure is not null)
{
throw new AggregateException(
"Rendering failed and its GPU measurement could not be closed.",
measuredRenderFailure,
measurementFailure);
}
}
var outcome = new RenderFrameOutcome(world, presentation);
_diagnostics.Publish(input, outcome);
_postDiagnostics.Process(input, outcome);

View file

@ -93,9 +93,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
private readonly ClipFrame? _clip;
private readonly TerrainModernRenderer? _terrain;
private readonly SceneLightingUboBinding? _lighting;
private readonly FrameProfiler _profiler;
private readonly GL _gl;
public RuntimeRenderFrameBeginResources(
TextureCache? textures,
WbDrawDispatcher? dispatcher,
@ -105,9 +102,7 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
TextRenderer? uiText,
ClipFrame? clip,
TerrainModernRenderer? terrain,
SceneLightingUboBinding? lighting,
FrameProfiler profiler,
GL gl)
SceneLightingUboBinding? lighting)
{
_textures = textures;
_dispatcher = dispatcher;
@ -118,8 +113,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_clip = clip;
_terrain = terrain;
_lighting = lighting;
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void Begin(int gpuSlot)
@ -134,7 +127,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_clip?.BeginFrame(gpuSlot);
_terrain?.BeginFrame(gpuSlot);
_lighting?.BeginFrame(gpuSlot);
_profiler.FrameBoundary(_gl);
}
}

View file

@ -0,0 +1,533 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace AcDream.Cli;
public static partial class PerformanceTools
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
public static int SummarizeFrameHistory(string[] args)
{
if (args.Length < 4)
{
Console.Error.WriteLine(
"usage: AcDream.Cli summarize-frame-history "
+ "<frame-history.csv> <checkpoints.jsonl> <markers.log> <out.json>");
return 2;
}
try
{
IReadOnlyList<FrameRow> frames = ReadFrames(args[0]);
IReadOnlyList<CheckpointRow> checkpoints = ReadCheckpoints(args[1]);
IReadOnlyList<PortalMarker> portals = ReadPortalMarkers(args[2]);
var summary = new FrameHistoryReport(
SourceFrameHistory: Path.GetFileName(args[0]),
FrameCount: frames.Count,
FirstFrameUtc: frames.Count == 0 ? null : frames[0].TimestampUtc,
LastFrameUtc: frames.Count == 0 ? null : frames[^1].TimestampUtc,
Route: SummarizeWindow(frames),
CheckpointWindows: checkpoints.Select(checkpoint =>
new NamedWindowSummary(
checkpoint.Name,
checkpoint.TimestampUtc.AddSeconds(-5),
checkpoint.TimestampUtc,
SummarizeWindow(frames.Where(frame =>
frame.TimestampUtc >= checkpoint.TimestampUtc.AddSeconds(-5)
&& frame.TimestampUtc <= checkpoint.TimestampUtc)),
checkpoint.ProcessAllocationRateBytesPerSecond))
.ToArray(),
PortalWindows: portals.Select(portal =>
new PortalWindowSummary(
portal.Destination,
portal.TimestampUtc,
SummarizeWindow(frames.Where(frame =>
frame.TimestampUtc >= portal.TimestampUtc.AddSeconds(-10)
&& frame.TimestampUtc <= portal.TimestampUtc.AddSeconds(5))),
frames.Where(frame =>
frame.TimestampUtc >= portal.TimestampUtc.AddSeconds(-10)
&& frame.TimestampUtc <= portal.TimestampUtc.AddSeconds(5))
.OrderByDescending(frame => frame.CpuUs)
.Take(20)
.Select(frame => new WorstFrame(
frame.Frame,
Math.Round(
(frame.TimestampUtc - portal.TimestampUtc).TotalMilliseconds,
3),
frame.CpuUs,
frame.GpuUs,
frame.AllocBytes,
frame.UpdateUs,
frame.UploadUs,
frame.PacingUs))
.ToArray()))
.ToArray());
string output = Path.GetFullPath(args[3]);
Directory.CreateDirectory(
Path.GetDirectoryName(output)
?? throw new InvalidOperationException("Output path has no directory."));
File.WriteAllText(output, JsonSerializer.Serialize(summary, JsonOptions));
Console.WriteLine($"wrote frame-history summary: {output}");
return 0;
}
catch (Exception error)
{
Console.Error.WriteLine($"frame-history summary failed: {error.Message}");
return 1;
}
}
public static int CompareScreenshots(string[] args)
{
if (args.Length < 3)
{
Console.Error.WriteLine(
"usage: AcDream.Cli compare-screenshots "
+ "<expected.png> <actual.png> <out.json> "
+ "[channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]");
return 2;
}
try
{
int tolerance = args.Length >= 4
? int.Parse(args[3], CultureInfo.InvariantCulture)
: 2;
double maxDifferentFraction = args.Length >= 5
? double.Parse(args[4], CultureInfo.InvariantCulture)
: 0.001;
string? maskPath = args.Length >= 6 ? args[5] : null;
ScreenshotComparison comparison = CompareImages(
args[0],
args[1],
tolerance,
maxDifferentFraction,
maskPath);
string output = Path.GetFullPath(args[2]);
Directory.CreateDirectory(
Path.GetDirectoryName(output)
?? throw new InvalidOperationException("Output path has no directory."));
File.WriteAllText(output, JsonSerializer.Serialize(comparison, JsonOptions));
Console.WriteLine(
$"screenshot comparison {(comparison.Passed ? "passed" : "failed")}: "
+ $"{comparison.DifferentPixelFraction:P6} different");
return comparison.Passed ? 0 : 1;
}
catch (Exception error)
{
Console.Error.WriteLine($"screenshot comparison failed: {error.Message}");
return 1;
}
}
public static ScreenshotComparison CompareImages(
string expectedPath,
string actualPath,
int channelTolerance,
double maxDifferentFraction,
string? maskPath = null)
{
if ((uint)channelTolerance > byte.MaxValue)
throw new ArgumentOutOfRangeException(nameof(channelTolerance));
if (maxDifferentFraction is < 0 or > 1 || double.IsNaN(maxDifferentFraction))
throw new ArgumentOutOfRangeException(nameof(maxDifferentFraction));
using Image<Rgba32> expected = Image.Load<Rgba32>(expectedPath);
using Image<Rgba32> actual = Image.Load<Rgba32>(actualPath);
using Image<Rgba32>? mask = maskPath is null
? null
: Image.Load<Rgba32>(maskPath);
if (expected.Width != actual.Width || expected.Height != actual.Height)
{
return new ScreenshotComparison(
Passed: false,
Width: actual.Width,
Height: actual.Height,
ExpectedWidth: expected.Width,
ExpectedHeight: expected.Height,
ChannelTolerance: channelTolerance,
MaxDifferentPixelFraction: maxDifferentFraction,
ComparedPixels: 0,
MaskedPixels: 0,
DifferentPixels: 0,
DifferentPixelFraction: 1,
MaximumChannelDelta: byte.MaxValue,
MeanAbsoluteChannelDelta: 0,
RootMeanSquareChannelDelta: 0,
ExpectedPath: Path.GetFileName(expectedPath),
ActualPath: Path.GetFileName(actualPath),
MaskPath: maskPath is null ? null : Path.GetFileName(maskPath));
}
if (mask is not null
&& (mask.Width != expected.Width || mask.Height != expected.Height))
{
throw new InvalidDataException(
"Screenshot mask dimensions must match the compared images.");
}
long comparedPixels = 0;
long maskedPixels = 0;
long differentPixels = 0;
long absoluteDeltaSum = 0;
long squaredDeltaSum = 0;
int maximumDelta = 0;
for (int y = 0; y < expected.Height; y++)
{
for (int x = 0; x < expected.Width; x++)
{
if (mask is not null && mask[x, y].A != 0)
{
maskedPixels++;
continue;
}
comparedPixels++;
Rgba32 left = expected[x, y];
Rgba32 right = actual[x, y];
int redDelta = Math.Abs(left.R - right.R);
int greenDelta = Math.Abs(left.G - right.G);
int blueDelta = Math.Abs(left.B - right.B);
int alphaDelta = Math.Abs(left.A - right.A);
absoluteDeltaSum += redDelta + greenDelta + blueDelta + alphaDelta;
squaredDeltaSum +=
redDelta * redDelta
+ greenDelta * greenDelta
+ blueDelta * blueDelta
+ alphaDelta * alphaDelta;
maximumDelta = Math.Max(
maximumDelta,
Math.Max(
Math.Max(redDelta, greenDelta),
Math.Max(blueDelta, alphaDelta)));
bool different =
redDelta > channelTolerance
|| greenDelta > channelTolerance
|| blueDelta > channelTolerance
|| alphaDelta > channelTolerance;
if (different)
differentPixels++;
}
}
double differingFraction = comparedPixels == 0
? throw new InvalidDataException(
"Screenshot mask excludes every pixel; no comparison is possible.")
: differentPixels / (double)comparedPixels;
long comparedChannels = comparedPixels * 4;
return new ScreenshotComparison(
Passed: differingFraction <= maxDifferentFraction,
Width: actual.Width,
Height: actual.Height,
ExpectedWidth: expected.Width,
ExpectedHeight: expected.Height,
ChannelTolerance: channelTolerance,
MaxDifferentPixelFraction: maxDifferentFraction,
ComparedPixels: comparedPixels,
MaskedPixels: maskedPixels,
DifferentPixels: differentPixels,
DifferentPixelFraction: differingFraction,
MaximumChannelDelta: maximumDelta,
MeanAbsoluteChannelDelta: comparedChannels == 0
? 0
: absoluteDeltaSum / (double)comparedChannels,
RootMeanSquareChannelDelta: comparedChannels == 0
? 0
: Math.Sqrt(squaredDeltaSum / (double)comparedChannels),
ExpectedPath: Path.GetFileName(expectedPath),
ActualPath: Path.GetFileName(actualPath),
MaskPath: maskPath is null ? null : Path.GetFileName(maskPath));
}
private static IReadOnlyList<FrameRow> ReadFrames(string path)
{
string[] lines = File.ReadAllLines(path);
if (lines.Length == 0)
throw new InvalidDataException("Frame-history CSV is empty.");
string[] header = lines[0].Split(',');
var columns = header
.Select((name, index) => (name, index))
.ToDictionary(pair => pair.name, pair => pair.index, StringComparer.Ordinal);
string[] required =
[
"frame", "timestamp_utc", "cpu_us", "gpu_us", "alloc_bytes",
"update_us", "upload_us", "imgui_us", "pacing_us",
];
foreach (string name in required)
{
if (!columns.ContainsKey(name))
throw new InvalidDataException($"Frame-history CSV is missing '{name}'.");
}
var rows = new List<FrameRow>(lines.Length - 1);
for (int lineNumber = 1; lineNumber < lines.Length; lineNumber++)
{
if (string.IsNullOrWhiteSpace(lines[lineNumber]))
continue;
string[] values = lines[lineNumber].Split(',');
try
{
rows.Add(new FrameRow(
ParseInt(values, columns, "frame"),
DateTime.Parse(
values[columns["timestamp_utc"]],
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
ParseLong(values, columns, "cpu_us"),
ParseLong(values, columns, "gpu_us"),
ParseLong(values, columns, "alloc_bytes"),
ParseLong(values, columns, "update_us"),
ParseLong(values, columns, "upload_us"),
ParseLong(values, columns, "imgui_us"),
ParseLong(values, columns, "pacing_us")));
}
catch (Exception error) when (
error is FormatException
or IndexOutOfRangeException
or KeyNotFoundException)
{
throw new InvalidDataException(
$"Invalid frame-history row {lineNumber + 1}: {error.Message}",
error);
}
}
return rows;
}
private static IReadOnlyList<CheckpointRow> ReadCheckpoints(string path)
{
var rows = new List<CheckpointRow>();
CheckpointRaw? previous = null;
foreach (string line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line))
continue;
using JsonDocument document = JsonDocument.Parse(line);
JsonElement root = document.RootElement;
JsonElement resources = root.GetProperty("resources");
var current = new CheckpointRaw(
root.GetProperty("name").GetString()
?? throw new InvalidDataException("Checkpoint name is null."),
root.GetProperty("timestampUtc").GetDateTime().ToUniversalTime(),
resources.GetProperty("processTotalAllocatedBytes").GetInt64());
double? allocationRate = null;
if (previous is not null)
{
double seconds = (current.TimestampUtc - previous.TimestampUtc).TotalSeconds;
if (seconds > 0)
{
allocationRate = Math.Max(
0,
current.ProcessAllocatedBytes - previous.ProcessAllocatedBytes)
/ seconds;
}
}
rows.Add(new CheckpointRow(
current.Name,
current.TimestampUtc,
allocationRate));
previous = current;
}
return rows;
}
private static IReadOnlyList<PortalMarker> ReadPortalMarkers(string path)
{
var markers = new List<PortalMarker>();
foreach (string line in File.ReadLines(path))
{
Match match = PortalMarkerPattern().Match(line);
if (!match.Success)
continue;
markers.Add(new PortalMarker(
DateTime.Parse(
match.Groups["timestamp"].Value,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
match.Groups["destination"].Value));
}
return markers;
}
private static WindowSummary SummarizeWindow(IEnumerable<FrameRow> source)
{
FrameRow[] rows = source.OrderBy(row => row.TimestampUtc).ToArray();
return new WindowSummary(
FrameCount: rows.Length,
DurationSeconds: rows.Length < 2
? 0
: (rows[^1].TimestampUtc - rows[0].TimestampUtc).TotalSeconds,
CpuUs: SummarizeMetric(rows.Select(row => row.CpuUs)),
GpuUs: SummarizeMetric(rows.Where(row => row.GpuUs >= 0).Select(row => row.GpuUs)),
AllocationBytes: SummarizeMetric(rows.Select(row => row.AllocBytes)),
UpdateUs: SummarizeMetric(rows.Select(row => row.UpdateUs)),
UploadUs: SummarizeMetric(rows.Select(row => row.UploadUs)),
ImGuiUs: SummarizeMetric(rows.Select(row => row.ImGuiUs)),
PacingUs: SummarizeMetric(rows.Select(row => row.PacingUs)),
TotalPositiveAllocationBytes: rows.Sum(row => Math.Max(0, row.AllocBytes)),
CpuStandardDeviationUs: StandardDeviation(rows.Select(row => row.CpuUs)));
}
private static MetricSummary SummarizeMetric(IEnumerable<long> source)
{
long[] values = source.Order().ToArray();
if (values.Length == 0)
return new MetricSummary(0, 0, 0, 0, 0, 0, 0);
return new MetricSummary(
values.Length,
Percentile(values, 0.50),
Percentile(values, 0.95),
Percentile(values, 0.99),
Percentile(values, 0.999),
values[^1],
values.Average());
}
private static long Percentile(long[] sorted, double percentile)
{
int index = (int)Math.Ceiling(sorted.Length * percentile) - 1;
return sorted[Math.Clamp(index, 0, sorted.Length - 1)];
}
private static double StandardDeviation(IEnumerable<long> source)
{
long[] values = source.ToArray();
if (values.Length == 0)
return 0;
double average = values.Average();
double variance = values.Sum(value => Math.Pow(value - average, 2)) / values.Length;
return Math.Sqrt(variance);
}
private static int ParseInt(
string[] values,
IReadOnlyDictionary<string, int> columns,
string name) =>
int.Parse(values[columns[name]], CultureInfo.InvariantCulture);
private static long ParseLong(
string[] values,
IReadOnlyDictionary<string, int> columns,
string name) =>
long.Parse(values[columns[name]], CultureInfo.InvariantCulture);
[GeneratedRegex(
"^(?<timestamp>\\S+) materialized destination='(?<destination>[^']+)'",
RegexOptions.CultureInvariant)]
private static partial Regex PortalMarkerPattern();
private sealed record FrameRow(
int Frame,
DateTime TimestampUtc,
long CpuUs,
long GpuUs,
long AllocBytes,
long UpdateUs,
long UploadUs,
long ImGuiUs,
long PacingUs);
private sealed record CheckpointRaw(
string Name,
DateTime TimestampUtc,
long ProcessAllocatedBytes);
private sealed record CheckpointRow(
string Name,
DateTime TimestampUtc,
double? ProcessAllocationRateBytesPerSecond);
private sealed record PortalMarker(DateTime TimestampUtc, string Destination);
}
public sealed record MetricSummary(
int Count,
long P50,
long P95,
long P99,
long P999,
long Maximum,
double Mean);
public sealed record WindowSummary(
int FrameCount,
double DurationSeconds,
MetricSummary CpuUs,
MetricSummary GpuUs,
MetricSummary AllocationBytes,
MetricSummary UpdateUs,
MetricSummary UploadUs,
MetricSummary ImGuiUs,
MetricSummary PacingUs,
long TotalPositiveAllocationBytes,
double CpuStandardDeviationUs);
public sealed record NamedWindowSummary(
string Name,
DateTime StartUtc,
DateTime EndUtc,
WindowSummary Metrics,
double? ProcessAllocationRateBytesPerSecond);
public sealed record WorstFrame(
int Frame,
double RelativeToMaterializationMs,
long CpuUs,
long GpuUs,
long AllocationBytes,
long UpdateUs,
long UploadUs,
long PacingUs);
public sealed record PortalWindowSummary(
string Destination,
DateTime MaterializedUtc,
WindowSummary Metrics,
IReadOnlyList<WorstFrame> WorstFrames);
public sealed record FrameHistoryReport(
string SourceFrameHistory,
int FrameCount,
DateTime? FirstFrameUtc,
DateTime? LastFrameUtc,
WindowSummary Route,
IReadOnlyList<NamedWindowSummary> CheckpointWindows,
IReadOnlyList<PortalWindowSummary> PortalWindows);
public sealed record ScreenshotComparison(
bool Passed,
int Width,
int Height,
int ExpectedWidth,
int ExpectedHeight,
int ChannelTolerance,
double MaxDifferentPixelFraction,
long ComparedPixels,
long MaskedPixels,
long DifferentPixels,
double DifferentPixelFraction,
int MaximumChannelDelta,
double MeanAbsoluteChannelDelta,
double RootMeanSquareChannelDelta,
string ExpectedPath,
string ActualPath,
string? MaskPath);

View file

@ -8,6 +8,16 @@ using DatReaderWriter.Types;
using Env = System.Environment;
// ─── subcommand dispatch ────────────────────────────────────────────────────
if (args.Length >= 1 && args[0] == "summarize-frame-history")
{
return PerformanceTools.SummarizeFrameHistory(args[1..]);
}
if (args.Length >= 1 && args[0] == "compare-screenshots")
{
return PerformanceTools.CompareScreenshots(args[1..]);
}
if (args.Length >= 1 && args[0] == "dump-vitals-bars")
{
string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");