fix: FramePump paces one frame per deadline slot — duplicate-on-lag, never skip (slice 15)

ty-1723/1726 device takes showed accelerated playback + audio tail cut-off:
a render overrun (~35ms vs the 16.6ms slot) SKIPPED the missed slots (slice 10's
freshness choice), so a 60fps-authoring pump wrote one frame per 35ms into a
60fps container — 1723: 697 frames/11.62s vs 11.84s audio; 1726: 163/2.72s vs
2.93s, video ending 0.21-0.24s early.

OBS never leaves a wall-time hole: the video thread emits one frame per tick and
a lagging producer DUPLICATES the newest frame ("lagged frames due to rendering
lag/stalls" — obs-output.c; "If the video frame queue is full, it will duplicate
the last frame" — docs.obsproject.com/backend-design). The pump's submit is now a
bounded catch-up over the missed slots (while now >= nextTick), fresh on the first,
repeated after — duration == wall, judder not fast-forward. Safe because Channel.
TryWrite never blocks (the take-9 smear was the blocking pipe-write; each emit is
nanoseconds). Burned frame index moved inside the loop: every emitted slot carries
its own +1 (also fixes the old unconditional pre-gate bump that gapped the judge
sequence on non-submitting fast-render iterations).

Good Dog test: Pump_Overrun_Renders_EmitsEverySlot_NotSkipped (60fps, 35ms render
cost, asserts >=0.65 of the wall slots emitted). 294/294 green, 0 warnings.

No push — web/A/V work is commit-local until greenlight.
This commit is contained in:
2026-09-14 17:40:59 -07:00
parent 394d86b94c
commit 76f51e6f4e
5 changed files with 176 additions and 95 deletions
+35
View File
@@ -395,6 +395,41 @@ public class FramePumpTests
}
}
/// <summary>The ONE integration test for the slice-15 pacing fix (2026-09-14):
/// a render that overruns the deadline must NOT skip the missed slots — a skip
/// authors fast playback: ty-1726 rendered one frame per 35ms overrun into a
/// 60fps container (163 frames for 2.93s of audio) and cut the audio tail off.
/// OBS fills an overrun's slots by DUPLICATING the newest frame (libobs
/// media-io/video-io.c; docs.obsproject.com/backend-design: "If the video frame
/// queue is full, it will duplicate the last frame") so recording duration stays
/// equal to wall duration. Here a 60fps pump renders every frame at ~35ms cost
/// (a genuine overrun): the slice-10 keep-fresh loop authored ~1 per 35ms; the
/// slot-filling loop must emit a frame for ~every 16.6ms of wall time.</summary>
[Fact]
public async Task Pump_Overrun_Renders_EmitsEverySlot_NotSkipped()
{
const int fps = 60;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
System.Threading.Thread.Sleep(35);
return null;
});
await pump.StartAsync();
await Task.Delay(7000); // ~420 slots of wall time
await pump.StopAsync();
// Slot-faithful: ~1 frame per 16.6ms -> ~415 in the window. Skipping (the
// slice-10 behavior): ~1 per 35ms -> ~200. 0.65 * fps * (elapsed seconds)
// is a middle line a skipped pump can never cross, a filling one only fails
// on a pathological run.
Assert.True(encoder.Frames.Count >= (int)(7.0 * fps * 0.65),
$"pump emitted {encoder.Frames.Count} frames in ~7s wall at {fps}fps — "
+ $"{7.0 / encoder.Frames.Count * fps:F1}x playback — an overrun must "
+ "DUPLICATE the newest frame per missed slot (OBS), not skip it");
}
/// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes