diff --git a/src/AcDream.App/Diagnostics/GpuFrameTimer.cs b/src/AcDream.App/Diagnostics/GpuFrameTimer.cs
new file mode 100644
index 00000000..d75621e9
--- /dev/null
+++ b/src/AcDream.App/Diagnostics/GpuFrameTimer.cs
@@ -0,0 +1,87 @@
+using System;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Diagnostics;
+
+///
+/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
+/// queries (depth 4, so results are
+/// read ~3 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.
+///
+/// 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.
+///
+internal sealed class GpuFrameTimer : IDisposable
+{
+ private const int RingDepth = 4;
+
+ private readonly GL _gl;
+ private readonly uint[] _queries = new uint[RingDepth];
+ private readonly bool[] _begun = new bool[RingDepth];
+ private int _frameIndex;
+ private bool _queryActive;
+
+ public GpuFrameTimer(GL gl)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ for (int i = 0; i < RingDepth; i++)
+ _queries[i] = _gl.GenQuery();
+ }
+
+ ///
+ /// 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.
+ ///
+ public long? FrameBoundary()
+ {
+ if (_queryActive)
+ {
+ _gl.EndQuery(QueryTarget.TimeElapsed);
+ _queryActive = false;
+ }
+
+ long? completedUs = null;
+ int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
+ if (_begun[readSlot])
+ {
+ _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.
+ }
+
+ _gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
+ _begun[readSlot] = true;
+ _queryActive = true;
+ _frameIndex++;
+ return completedUs;
+ }
+
+ /// End any active query without beginning a new one (used when the profiler is toggled off mid-session).
+ public void Stop()
+ {
+ if (_queryActive)
+ {
+ _gl.EndQuery(QueryTarget.TimeElapsed);
+ _queryActive = false;
+ }
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ for (int i = 0; i < RingDepth; i++)
+ _gl.DeleteQuery(_queries[i]);
+ }
+}
diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index 872285e4..b265cb1c 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -567,4 +567,21 @@ public static class RenderingDiagnostics
///
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
=> renderRootResolved && IsEnvCellId(playerCellId);
+
+ ///
+ /// MP0 (2026-07-05) — master toggle for the permanent frame profiler
+ /// (AcDream.App.Diagnostics.FrameProfiler): CPU frame time
+ /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
+ /// per-frame allocation counters, reported as one [frame-prof]
+ /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
+ /// do NOT strip with session probes. The whole-frame GPU query is
+ /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested
+ /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
+ /// that flag — the 2026-06-23 "separate flags" measurement lesson).
+ /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable
+ /// via the DebugPanel mirror (DebugVM.FrameProf).
+ /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
+ ///
+ public static bool FrameProfEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
}