using System;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AcDream.Core.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// Stage indices for per-frame CPU attribution.
public enum FrameStage
{
/// Whole OnUpdate body (simulation + streaming apply).
Update = 0,
/// WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.
Upload = 1,
/// ImGui Render (dev overlay).
ImGui = 2,
}
///
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// FrameBoundary call at the top of GameWindow.OnRender
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU TimeElapsed query (via ), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// () attribute CPU time to Update / Upload /
/// ImGui. Emits one [frame-prof] line every ~5 s while
/// is true; costs one
/// bool check per frame when off.
///
/// Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under ACDREAM_WB_DIAG=1
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
///
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues().Length;
private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
private readonly FrameStatsBuffer[] _stageUs;
private readonly long[] _stageAccumTicks;
private readonly bool _wbDiagActive =
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
private long _lastAllocBytes;
private long _lastReportTicks;
private int _gc0Base, _gc1Base, _gc2Base;
private int _framesInWindow;
private int _ownerThreadId;
private bool _threadWarned;
private bool _wbDiagNoticePrinted;
private bool _wasEnabled;
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
_stageAccumTicks = new long[StageCount];
}
///
/// Call as the FIRST statement of GameWindow.OnRender.
///
public void FrameBoundary(GL gl)
{
bool enabled = RenderingDiagnostics.FrameProfEnabled;
if (!enabled)
{
if (_wasEnabled) { _gpuTimer?.Stop(); _wasEnabled = false; _lastBoundaryTimestamp = 0; }
return;
}
if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId;
else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId)
{
_threadWarned = true;
Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable");
}
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
// baselines, emit nothing.
_wasEnabled = true;
_lastReportTicks = DateTime.UtcNow.Ticks;
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)");
}
}
else
{
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
_cpuUs.Push(cpuUs);
_allocBytes.Push(allocNow - _lastAllocBytes);
for (int i = 0; i < StageCount; i++)
{
_stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
if (_gpuTimer?.FrameBoundary() is long gpuUs)
_gpuUs.Push(gpuUs);
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
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));
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0;
_cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset();
for (int i = 0; i < StageCount; i++) _stageUs[i].Reset();
}
}
///
/// Attribute the enclosed CPU time to .
/// Usage: using var _ = profiler.BeginStage(FrameStage.Update);.
/// Zero-cost (default scope) when the profiler is off.
///
public StageScope BeginStage(FrameStage stage)
=> RenderingDiagnostics.FrameProfEnabled
? new StageScope(this, stage, Stopwatch.GetTimestamp())
: default;
internal void EndStage(FrameStage stage, long startTimestamp)
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
/// Pure report formatter — unit-tested; invariant culture.
public static string FormatReport(
int frameCount,
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
FrameStatsBuffer alloc, int gc0, int gc1, int gc2,
FrameStatsBuffer[] stages)
{
var ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder(256);
sb.Append("[frame-prof] n=").Append(frameCount);
sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}",
cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0,
cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0);
if (gpuActive)
sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}",
gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0);
else
sb.Append(" | gpu=off(wbdiag)");
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}",
alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2);
string[] names = { "upd", "upl", "imgui" };
for (int i = 0; i < stages.Length && i < names.Length; i++)
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}",
names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);
return sb.ToString();
}
public void Dispose() => _gpuTimer?.Dispose();
}
/// Disposable stage scope; default instance is a no-op.
public readonly struct StageScope : IDisposable
{
private readonly FrameProfiler? _owner;
private readonly FrameStage _stage;
private readonly long _start;
internal StageScope(FrameProfiler owner, FrameStage stage, long start)
{
_owner = owner; _stage = stage; _start = start;
}
public void Dispose() => _owner?.EndStage(_stage, _start);
}