From 5a8bf242bebf76501bfbfef528a7a243c73baf25 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:05:24 -0700 Subject: [PATCH] Frame generation: warm cost estimate, re-probe, guest-paced breaker --- runtime/include/frame_gen.h | 35 +++++++++ runtime/include/gpu_gl_renderer.h | 6 +- runtime/src/frame_gen.c | 63 +++++++++++++++++ runtime/src/gpu_gl_renderer.c | 113 +++++++++++++++++++++++------- runtime/src/main.cpp | 10 +-- runtime/tests/test_frame_gen.c | 75 +++++++++++++++++++- 6 files changed, 267 insertions(+), 35 deletions(-) diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 5c83a03a..418c9b72 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -89,6 +89,41 @@ void fg_breaker_init(FgBreaker *b, double base_hold, double max_hold, double rep void fg_breaker_trip(FgBreaker *b, double now, const char *reason); int fg_breaker_open(const FgBreaker *b, double now); /* 1 = generation allowed */ +/* The cost of one generated frame, as the plan sees it. + * - Cold samples are discarded: the first frames after the surfaces were + * (re)allocated or generation was enabled pay first-touch costs (driver + * allocation, page faults) that steady state never sees; fg_cost_cold() + * marks the next `n` samples as such. + * - A stale estimate is re-probed: when the estimate has kept the plan at + * zero for `probe_s`, fg_cost_estimate() reports 0 (unknown) once so one + * frame is generated and measured, and that probe's sample replaces the + * estimate instead of blending into it. A probe that still does not fit + * doubles the wait before the next one (up to max_probe_s); one that fits + * resets it. */ +typedef struct FgCost { + double ema; /* seconds, 0 = unknown */ + uint32_t samples, discarded, probes; + int cold; /* samples still to discard */ + int probing; /* the next sample is a probe's */ + double blocked_since;/* when the estimate started keeping the plan at 0 */ + double probe_at; /* when the probe was granted */ + double probe_s, base_probe_s, max_probe_s; +} FgCost; +void fg_cost_init(FgCost *c, double probe_s, double max_probe_s); +void fg_cost_cold(FgCost *c, int n); +/* One measured frame; `fit_s` is what one generated frame may cost to fit. */ +void fg_cost_add(FgCost *c, double cost_s, double fit_s); +/* The estimate to plan with at `now`: 0 when unknown or due for a probe. + * `fit_s` as above: an estimate above it is what blocks the plan. */ +double fg_cost_estimate(FgCost *c, double now, double fit_s); + +/* The guest's own pacing: frame boundaries against a schedule that advances + * one interval per frame. Late only when the guest slipped more than `slack_s` + * behind its schedule (jitter, a long frame followed by a short one, does not + * count); the schedule then restarts from now. Early boundaries pull it in. */ +typedef struct FgPace { double next; int primed; } FgPace; +int fg_pace_note(FgPace *p, double now, double period_s, double slack_s); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 94ae9c57..ebc6edb2 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -330,13 +330,13 @@ void gl_renderer_dynres_stats(GlDynresStats *out); * in-between frames drawn by the render thread from the last two game * frames' draw lists when it has time to spare. Off by default; with it off * nothing changes. set: a sync point. configure: the display's refresh and - * the guest's frame rate (any thread). hold: trip the breaker (generation - * stays off for a few seconds), e.g. while dynamic resolution is over budget. + * the guest's frame rate (any thread). hold: pause generation for `secs` + * (not a breaker trip), e.g. while dynamic resolution is over budget. * json: the debug server's {"cmd":"frame_gen"} fields (no sync point). */ void gl_renderer_set_frame_generation(int on); int gl_renderer_frame_generation(void); void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); -void gl_renderer_frame_gen_hold(const char *reason); +void gl_renderer_frame_gen_hold(const char *reason, double secs); int gl_renderer_frame_gen_json(char *out, int cap); /* Host time the renderer spent, as running totals in performance-counter * ticks (only kept while dynamic resolution is on): waits for the frame diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 867defb4..d2135b51 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -190,3 +190,66 @@ void fg_breaker_trip(FgBreaker *b, double now, const char *reason) { } int fg_breaker_open(const FgBreaker *b, double now) { return now >= b->until; } + +void fg_cost_init(FgCost *c, double probe_s, double max_probe_s) { + memset(c, 0, sizeof *c); + c->base_probe_s = c->probe_s = probe_s; + c->max_probe_s = max_probe_s; + c->blocked_since = -1.0; +} + +void fg_cost_cold(FgCost *c, int n) { if (n > c->cold) c->cold = n; } + +void fg_cost_add(FgCost *c, double cost_s, double fit_s) { + if (cost_s <= 0.0) return; + if (c->cold > 0) { c->cold--; c->discarded++; return; } + c->samples++; + if (c->probing || c->ema <= 0.0) { + c->ema = cost_s; + if (c->probing) { + c->probing = 0; + if (fit_s > 0.0 && cost_s > fit_s) { + c->probe_s *= 2.0; + if (c->probe_s > c->max_probe_s) c->probe_s = c->max_probe_s; + } else { + c->probe_s = c->base_probe_s; + } + } + } else { + c->ema = c->ema * 0.8 + cost_s * 0.2; + } + if (fit_s <= 0.0 || c->ema <= fit_s) c->blocked_since = -1.0; +} + +double fg_cost_estimate(FgCost *c, double now, double fit_s) { + if (c->ema <= 0.0) return 0.0; + if (c->probing) { + /* A probe in flight; one never drawn (no room) is given up. */ + if (now - c->probe_at < c->probe_s) return c->ema; + c->probing = 0; + c->blocked_since = now; + } + if (fit_s > 0.0 && c->ema > fit_s) { + if (c->blocked_since < 0.0) c->blocked_since = now; + else if (now - c->blocked_since >= c->probe_s) { + c->blocked_since = now; + c->probing = 1; + c->probe_at = now; + c->probes++; + return 0.0; + } + } else { + c->blocked_since = -1.0; + } + return c->ema; +} + +int fg_pace_note(FgPace *p, double now, double period_s, double slack_s) { + if (period_s <= 0.0) return 0; + if (!p->primed) { p->primed = 1; p->next = now + period_s; return 0; } + int late = 0; + if (now > p->next + slack_s) { late = 1; p->next = now; } + else if (now < p->next - period_s) p->next = now; /* early: pull in */ + p->next += period_s; + return late; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d40119dc..97374e1e 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -10023,7 +10023,6 @@ static _Atomic double s_fg_refresh_hz = 0.0, s_fg_guest_hz = 59.94; static _Atomic int s_fg_late = 0; /* emulation thread: a late frame */ static const char *_Atomic s_fg_hold_reason = NULL; static _Atomic uint64_t s_fg_hold_until = 0; /* rt_now_ns: no generation before */ -static uint64_t s_fg_emu_last_ns = 0; /* emulation thread */ static FgList s_fg_l[4]; /* older, newer, capturing, spare */ static int s_fg_older = 0, s_fg_newer = 1, s_fg_cur = 2; static int s_fg_pa = -1, s_fg_pb = -1; /* the pair a schedule draws from */ @@ -10041,7 +10040,15 @@ static FgMatchStats s_fg_mst; static FgBreaker s_fg_brk; static int s_fg_brk_init = 0; static uint64_t s_fg_bp_seen = 0; -static double s_fg_real_ema = 0.0, s_fg_gen_ema = 0.0; /* seconds */ +static double s_fg_real_ema = 0.0; /* seconds */ +/* A generated frame's cost: cold samples discarded, a blocking estimate + * re-probed (frame_gen.h FgCost). s_fg_fit_s: what one may cost to fit. */ +static FgCost s_fg_cost; +static int s_fg_cost_init = 0; +static double s_fg_fit_s = 0.0; +static FgPace s_fg_pace; /* emulation thread */ +static _Atomic uint64_t s_fg_last_gen_ns = 0; /* the last generated frame (rt_now_ns) */ +static uint64_t s_fg_ignored_late = 0, s_fg_ignored_bp = 0; static const char *s_fg_hold_why = NULL; static double s_fg_gen_gpu_ms = 0.0, s_fg_gen_cpu_ms = 0.0; /* the last one measured */ /* Schedule after a flip: n generated frames, then the real one. */ @@ -10070,6 +10077,18 @@ static double s_fg_last_match_ms = 0.0; static double fg_now_s(void) { return (double)rt_now_ns() * 1e-9; } +static FgCost *fg_cost(void) { + if (!s_fg_cost_init) { fg_cost_init(&s_fg_cost, 2.0, 16.0); s_fg_cost_init = 1; } + return &s_fg_cost; +} + +/* Breaker trips only count while generation is what could have caused + * them: a generated frame in the last half second. */ +static int fg_recently_generated(void) { + const uint64_t g = atomic_load(&s_fg_last_gen_ns); + return g && rt_now_ns() - g < 500000000ull; +} + /* Render thread: every swap's wall time. A swap waits for the compositor * (and on a busy WindowServer for its round trip); each generated frame adds * one, so the plan counts it. */ @@ -10352,6 +10371,22 @@ static void fg_invalidate(void) { fg_list_open(); } +/* A new surface's first touch (the driver backing its storage) happens here, + * when it is allocated, not inside the first generated frame; that frame's + * measured cost is discarded as well (cold). */ +static void fg_surface_warm(GLuint fbo) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, fbo); + glDisable(GL_SCISSOR_TEST); + glClearColor(0.f, 0.f, 0.f, 0.f); + glClearStencil(0); + glClearDepth(1.0); + glStencilMask(0xFF); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); + glFlush(); + fg_cost_cold(fg_cost(), 2); +} + /* Surfaces for the generated image, at the current level. */ static int fg_surfaces_ensure(int wide) { const int S = s_hr_scale; @@ -10364,6 +10399,7 @@ static int fg_surfaces_ensure(int wide) { s_fg_hr_S = 0; if (!hr_alloc_surface(S, &s_fg_hr_tex, &s_fg_hr_rb, &s_fg_hr_fbo)) return 0; s_fg_hr_S = S; + fg_surface_warm(s_fg_hr_fbo); } if (wide && (s_fg_w_S != S || s_fg_w_w != g_wide_w)) { if (s_fg_w_fbo) { @@ -10374,6 +10410,7 @@ static int fg_surfaces_ensure(int wide) { s_fg_w_S = 0; if (!wide_alloc_surface(S, &s_fg_w_tex, &s_fg_w_rb, &s_fg_w_fbo)) return 0; s_fg_w_S = S; s_fg_w_w = g_wide_w; + fg_surface_warm(s_fg_w_fbo); } return 1; } @@ -10662,8 +10699,11 @@ static int fg_generate(double t, int swap) { /* ---- frame generation: schedule (render thread) ---- */ static void rth_present_payload(uint16_t op, const uint8_t *p); /* below */ +/* Short first hold, doubling on repeats within 2 s of the last hold's end, + * up to 8 s: one hiccup costs a fraction of a second of generation. */ +#define FG_BRK_INIT() do { if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 0.5, 8.0, 2.0); s_fg_brk_init = 1; } } while (0) static void fg_trip(const char *why) { - if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } + FG_BRK_INIT(); fg_breaker_trip(&s_fg_brk, fg_now_s(), why); } @@ -10675,7 +10715,7 @@ static void fg_gen_cost_poll(void) { if (!avail) break; p_glGetQueryObjectui64v(s_fg_q[i], GL_QUERY_RESULT, &ns); double c = (double)(ns > s_fg_q_cpu[i] ? ns : s_fg_q_cpu[i]) * 1e-9; - s_fg_gen_ema = s_fg_gen_ema > 0.0 ? s_fg_gen_ema * 0.8 + c * 0.2 : c; + fg_cost_add(fg_cost(), c, s_fg_fit_s); s_fg_gen_gpu_ms = (double)ns * 1e-6; s_fg_gen_cpu_ms = (double)s_fg_q_cpu[i] * 1e-6; s_fg_qt++; @@ -10724,7 +10764,7 @@ static void fg_generate_timed(double t) { s_fg_presenting = 0; } s_rth_ov.valid = 0; - if (ok) s_fg_generated++; + if (ok) { s_fg_generated++; atomic_store(&s_fg_last_gen_ns, rt_now_ns()); } rthf_resume(); } @@ -10791,8 +10831,9 @@ static uint64_t fg_tick(void *user, uint64_t now) { const uint64_t bp = rt_backpressure_events(); const int ahead = rt_frames_ahead(); if (!s_fg_force && (ahead >= 1 || bp != s_fg_bp_seen)) { - if (bp != s_fg_bp_seen) fg_trip("queue backed up"); - else if (ahead >= 2) fg_trip("render thread behind"); + if (bp != s_fg_bp_seen) { + if (fg_recently_generated()) fg_trip("queue backed up"); else s_fg_ignored_bp++; + } else if (ahead >= 2) fg_trip("render thread behind"); s_fg_bp_seen = bp; fg_flush(); return 0; @@ -10864,26 +10905,40 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal if (B->valid && (B->disp[0] != disp[0] || B->disp[1] != disp[1])) B = A = NULL; if (B) { B->wide = wide; B->linear = wide ? a[3] : a[4]; B->force43 = wide ? 0 : a[5]; } const double now = fg_now_s(); - if (atomic_exchange(&s_fg_late, 0)) fg_trip("guest late"); + /* Guest lateness and backpressure trip the breaker only while generation + * runs: otherwise they are not generation's doing. */ + const int gen_recent = fg_recently_generated(); + if (atomic_exchange(&s_fg_late, 0)) { + if (gen_recent) fg_trip("guest late"); else s_fg_ignored_late++; + } const char *why = atomic_exchange(&s_fg_hold_reason, NULL); if (why) s_fg_hold_why = why; const int held = rt_now_ns() < atomic_load(&s_fg_hold_until); const uint64_t bp = rt_backpressure_events(); - if (bp != s_fg_bp_seen) { s_fg_bp_seen = bp; fg_trip("queue backed up"); } + if (bp != s_fg_bp_seen) { + s_fg_bp_seen = bp; + if (gen_recent) fg_trip("queue backed up"); else s_fg_ignored_bp++; + } if (stale) fg_trip("render thread behind"); - if (!s_fg_brk_init) { fg_breaker_init(&s_fg_brk, 3.0, 24.0, 10.0); s_fg_brk_init = 1; } - if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ + FG_BRK_INIT(); const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); const int slots = (int)floor(flip_s * rhz + 0.5); s_fg_last_slots = slots; + /* What one generated frame may cost (its swap included) to fit. */ + const double real_s = s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema; + s_fg_fit_s = 0.85 * flip_s - real_s - s_fg_swap_ema; + if (s_fg_qok > 0) fg_gen_cost_poll(); /* once per game frame: a poll flushes */ int n = 0; if (A && B && A->valid && B->valid && A->disp[2] == B->disp[2] && A->disp[3] == B->disp[3] && A->wide_w == B->wide_w && A->scale > 0) { + /* The surfaces exist (and are warm) before a frame is planned on them. */ + if (!s_hiw) (void)fg_surfaces_ensure(B->wide); if (s_fg_force) n = slots > 1 ? slots - 1 : 1; - else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) - n = fg_plan(flip_s, rhz, s_fg_real_ema * (double)s_fg_flip_vb + s_fg_swap_ema, - s_fg_gen_ema > 0.0 ? s_fg_gen_ema + s_fg_swap_ema : 0.0, 0.85, 7); + else if (fg_breaker_open(&s_fg_brk, now) && !stale && !held) { + const double est = fg_cost_estimate(fg_cost(), now, s_fg_fit_s); + n = fg_plan(flip_s, rhz, real_s, est > 0.0 ? est + s_fg_swap_ema : 0.0, 0.85, 7); + } if (n == 0) s_fg_skipped_plan++; } s_fg_last_n = n; @@ -10944,24 +10999,25 @@ void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { } /* Not a breaker trip: generation pauses while the real frames are under - * pressure (dynamic resolution over budget or stepping down) and for a second - * after, without the breaker's escalating holds. */ -void gl_renderer_frame_gen_hold(const char *reason) { + * pressure (dynamic resolution over budget or stepping down) for the caller's + * short tail, without the breaker's escalating holds. */ +void gl_renderer_frame_gen_hold(const char *reason, double secs) { if (!s_fg_on) return; + if (secs <= 0.0) secs = 0.1; atomic_store(&s_fg_hold_reason, reason ? reason : "hold"); - atomic_store(&s_fg_hold_until, rt_now_ns() + 1000000000ull); + const uint64_t until = rt_now_ns() + (uint64_t)(secs * 1e9); + if (until > atomic_load(&s_fg_hold_until)) atomic_store(&s_fg_hold_until, until); } -/* Emulation thread, once per frame boundary: a frame that took much longer - * than the guest's interval trips the breaker. */ +/* Emulation thread, once per frame boundary: the guest is late when it has + * slipped two intervals behind its own VBlank schedule. One long frame made + * up by short ones (scheduling jitter, a burst after a wait) is not. */ static void fg_note_guest_frame(void) { if (!s_fg_on) return; - const uint64_t now = rt_now_ns(); const double ghz = atomic_load(&s_fg_guest_hz); - if (s_fg_emu_last_ns && ghz > 1.0 && - (double)(now - s_fg_emu_last_ns) > 1.5e9 / ghz) + if (ghz <= 1.0) return; + if (fg_pace_note(&s_fg_pace, (double)rt_now_ns() * 1e-9, 1.0 / ghz, 2.0 / ghz)) atomic_store(&s_fg_late, 1); - s_fg_emu_last_ns = now; } int gl_renderer_frame_gen_json(char *out, int cap) { @@ -10975,13 +11031,14 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"match_ms\":%.3f," "\"prims\":%u,\"matched\":%u,\"moved\":%u,\"unmatched\":%u," "\"breaker_s\":%.2f,\"trips\":%u,\"last_trip\":\"%s\",\"held_s\":%.2f," - "\"last_hold\":\"%s\",\"swaps\":%llu", + "\"last_hold\":\"%s\",\"swaps\":%llu,\"gen_samples\":%u,\"gen_cold\":%u," + "\"gen_probes\":%u,\"ignored_late\":%llu,\"ignored_bp\":%llu", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, (unsigned long long)s_fg_flushed, (unsigned long long)s_fg_skipped_plan, s_fg_last_n, s_fg_last_slots, s_fg_flip_vb, atomic_load(&s_fg_refresh_hz), - s_fg_real_ema * 1e3, s_fg_gen_ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, + s_fg_real_ema * 1e3, fg_cost()->ema * 1e3, s_fg_gen_cpu_ms, s_fg_gen_gpu_ms, s_fg_swap_ema * 1e3, s_fg_last_match_ms, s_fg_mst.prims, s_fg_mst.matched, s_fg_mst.moved, s_fg_mst.unmatched, @@ -10989,7 +11046,9 @@ int gl_renderer_frame_gen_json(char *out, int cap) { s_fg_brk.reason ? s_fg_brk.reason : "", (double)(atomic_load(&s_fg_hold_until) > rt_now_ns() ? atomic_load(&s_fg_hold_until) - rt_now_ns() : 0) * 1e-9, - s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total); + s_fg_hold_why ? s_fg_hold_why : "", (unsigned long long)s_swaps_total, + fg_cost()->samples, fg_cost()->discarded, fg_cost()->probes, + (unsigned long long)s_fg_ignored_late, (unsigned long long)s_fg_ignored_bp); } /* ---- the recording vtable ------------------------------------------------ */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a94a43b4..97ec2d28 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9361,10 +9361,12 @@ static void dynres_tick_rt(double now_s, double wall, double period, int held, const int prev_level = c.level; const int level = dynrt_sample(&c, now_s, &smp); /* Frame generation only spends surplus: not while the real frames are - * over budget or the level is stepping down. */ - if (level < prev_level || c.over_streak > 0) - gl_renderer_frame_gen_hold(level < prev_level ? "dynres stepped down" - : "dynres over budget"); + * over budget (renewed every over-budget sample) and briefly after a + * step down, while the new level's first frames settle. */ + if (level < prev_level) + gl_renderer_frame_gen_hold("dynres stepped down", 0.25); + else if (c.over_streak > 0) + gl_renderer_frame_gen_hold("dynres over budget", 0.1); if (c.windows != g_dynres.rt_last_windows) { g_dynres.rt_last_windows = c.windows; g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index cdae85da..b89b7b16 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -1,7 +1,7 @@ /* Frame generation's renderer-independent half (src/frame_gen.c, * docs/FRAME_GENERATION.md): matching triangles of two frames, the * interpolation endpoints, the plan of how many in-between frames fit, and - * the breaker. */ + * the breaker, the generated-frame cost estimate and the guest pace. */ #include "frame_gen.h" #include @@ -139,7 +139,80 @@ static void test_breaker(void) { check(b.hold <= 24.0, "the hold is capped"); } +static void test_cost(void) { + FgCost c; + fg_cost_init(&c, 2.0, 16.0); + const double fit = 0.020; + check(fg_cost_estimate(&c, 0.0, fit) == 0.0, "unknown before any sample"); + /* Cold: the first frames after allocation are discarded. */ + fg_cost_cold(&c, 2); + fg_cost_add(&c, 0.070, fit); + fg_cost_add(&c, 0.040, fit); + check(c.ema == 0.0 && c.discarded == 2, "cold samples discarded"); + fg_cost_add(&c, 0.006, fit); + check(fabs(fg_cost_estimate(&c, 0.1, fit) - 0.006) < 1e-12, "first warm sample is the estimate"); + fg_cost_add(&c, 0.016, fit); + check(fabs(c.ema - 0.008) < 1e-12, "warm samples blend"); + /* A spike that pushes the estimate past the fit blocks the plan... */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + check(fg_cost_estimate(&c, 10.0, fit) > fit, "a high estimate blocks"); + check(fg_cost_estimate(&c, 11.9, fit) > fit, "... until the probe interval"); + /* ...for at most probe_s: then one probe, whose sample replaces it. */ + check(fg_cost_estimate(&c, 12.0, fit) == 0.0 && c.probes == 1, "stale estimate re-probed"); + check(fg_cost_estimate(&c, 12.03, fit) > fit, "one probe at a time"); + fg_cost_add(&c, 0.005, fit); + check(fabs(fg_cost_estimate(&c, 12.1, fit) - 0.005) < 1e-12, "the probe replaces the estimate"); + /* A probe that still does not fit backs off. */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + (void)fg_cost_estimate(&c, 20.0, fit); + check(fg_cost_estimate(&c, 22.0, fit) == 0.0, "second probe"); + fg_cost_add(&c, 0.050, fit); + check(c.probe_s == 4.0, "a probe that does not fit doubles the wait"); + check(fg_cost_estimate(&c, 25.9, fit) > fit && fg_cost_estimate(&c, 26.0, fit) == 0.0, + "next probe after the doubled wait"); + fg_cost_add(&c, 0.004, fit); + check(c.probe_s == 2.0, "a probe that fits resets the wait"); + /* A granted probe that was never drawn is given up, then re-granted. */ + for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit); + (void)fg_cost_estimate(&c, 40.0, fit); + check(fg_cost_estimate(&c, 42.0, fit) == 0.0, "probe granted"); + check(fg_cost_estimate(&c, 44.0, fit) > fit, "an undrawn probe expires"); + check(fg_cost_estimate(&c, 46.0, fit) == 0.0, "and is granted again"); + /* Re-allocation makes the next samples cold again. */ + fg_cost_cold(&c, 2); + fg_cost_add(&c, 0.5, fit); fg_cost_add(&c, 0.5, fit); + check(c.cold == 0 && c.ema < 0.5, "cold after re-allocation"); +} + +static void test_pace(void) { + FgPace p; + memset(&p, 0, sizeof p); + const double T = 1.0 / 59.94, slack = 2.0 * T; + double t = 0.0; + int late = 0; + for (int i = 0; i < 600; i++) { /* jitter: +-6 ms around the schedule */ + late += fg_pace_note(&p, t + ((i % 3) - 1) * 0.006, T, slack); + t += T; + } + check(late == 0, "jitter is not late"); + late = 0; /* one 40 ms frame made up by a burst */ + late += fg_pace_note(&p, t + 0.024, T, slack); t += T; + late += fg_pace_note(&p, t + 0.004, T, slack); t += T; + late += fg_pace_note(&p, t, T, slack); t += T; + check(late == 0, "a long frame made up is not late"); + /* A real slip: 60 ms gap. */ + check(fg_pace_note(&p, t + 0.045, T, slack) == 1, "a slip beyond two intervals is late"); + t += 0.045 + T; + check(fg_pace_note(&p, t, T, slack) == 0, "the schedule restarts after a slip"); + /* Slow but steady (a 50 Hz guest told 59.94): one trip, then resync each time it slips. */ + int trips = 0; + for (int i = 0; i < 60; i++) { t += 0.020; trips += fg_pace_note(&p, t, T, slack); } + check(trips > 0 && trips < 20, "a steadily slow guest is late now and then"); +} + int main(void) { + test_cost(); + test_pace(); test_identical_and_moved(); test_keys_limits_and_duplicates(); test_each_older_pairs_once();