diff --git a/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs b/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
new file mode 100644
index 00000000..080e6ac8
--- /dev/null
+++ b/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
@@ -0,0 +1,64 @@
+using System;
+
+namespace AcDream.App.Diagnostics;
+
+///
+/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
+/// (microseconds or bytes) with percentile/max over the current window.
+/// Pure and allocation-free after construction:
+/// sorts into a preallocated scratch array, so the 5-second report path
+/// allocates nothing. Not thread-safe — owned by the window loop thread.
+/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
+///
+public sealed class FrameStatsBuffer
+{
+ private readonly long[] _samples;
+ private readonly long[] _scratch;
+ private int _cursor;
+ private int _count;
+
+ public FrameStatsBuffer(int capacity)
+ {
+ if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
+ _samples = new long[capacity];
+ _scratch = new long[capacity];
+ }
+
+ public int Count => _count;
+
+ public void Push(long value)
+ {
+ _samples[_cursor] = value;
+ _cursor = (_cursor + 1) % _samples.Length;
+ if (_count < _samples.Length) _count++;
+ }
+
+ public void Reset()
+ {
+ _cursor = 0;
+ _count = 0;
+ }
+
+ ///
+ /// Nearest-rank percentile over the current window: element at
+ /// ceil(q·n) in the ascending sort (1-based), 0 when empty.
+ ///
+ public long Percentile(double q)
+ {
+ if (_count == 0) return 0;
+ Array.Copy(_samples, _scratch, _count);
+ Array.Sort(_scratch, 0, _count);
+ int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
+ if (rank < 1) rank = 1;
+ if (rank > _count) rank = _count;
+ return _scratch[rank - 1];
+ }
+
+ public long Max()
+ {
+ long max = 0;
+ for (int i = 0; i < _count; i++)
+ if (_samples[i] > max) max = _samples[i];
+ return max;
+ }
+}
diff --git a/tests/AcDream.App.Tests/FrameStatsBufferTests.cs b/tests/AcDream.App.Tests/FrameStatsBufferTests.cs
new file mode 100644
index 00000000..0d4cd438
--- /dev/null
+++ b/tests/AcDream.App.Tests/FrameStatsBufferTests.cs
@@ -0,0 +1,51 @@
+using AcDream.App.Diagnostics;
+using Xunit;
+
+namespace AcDream.App.Tests;
+
+public class FrameStatsBufferTests
+{
+ [Fact]
+ public void Percentiles_OnKnownDistribution_AreExact()
+ {
+ var buf = new FrameStatsBuffer(capacity: 100);
+ // 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100.
+ for (long i = 1; i <= 100; i++) buf.Push(i);
+
+ Assert.Equal(50, buf.Percentile(0.50));
+ Assert.Equal(95, buf.Percentile(0.95));
+ Assert.Equal(99, buf.Percentile(0.99));
+ Assert.Equal(100, buf.Max());
+ }
+
+ [Fact]
+ public void Push_PastCapacity_KeepsOnlyNewestWindow()
+ {
+ var buf = new FrameStatsBuffer(capacity: 4);
+ foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 })
+ buf.Push(v);
+ // The four 1000s were overwritten; window is {1,2,3,4}.
+ Assert.Equal(4, buf.Count);
+ Assert.Equal(4, buf.Max());
+ Assert.Equal(2, buf.Percentile(0.50));
+ }
+
+ [Fact]
+ public void Percentile_Empty_ReturnsZero()
+ {
+ var buf = new FrameStatsBuffer(capacity: 8);
+ Assert.Equal(0, buf.Percentile(0.95));
+ Assert.Equal(0, buf.Max());
+ Assert.Equal(0, buf.Count);
+ }
+
+ [Fact]
+ public void Reset_ClearsWindow()
+ {
+ var buf = new FrameStatsBuffer(capacity: 8);
+ buf.Push(5); buf.Push(7);
+ buf.Reset();
+ Assert.Equal(0, buf.Count);
+ Assert.Equal(0, buf.Percentile(0.5));
+ }
+}