Frame generation: warm cost estimate, re-probe, guest-paced breaker

This commit is contained in:
Shokunin
2026-10-06 15:05:24 -07:00
parent 1d38b0415d
commit 5a8bf242be
6 changed files with 267 additions and 35 deletions
+35
View File
@@ -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
+3 -3
View File
@@ -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
+63
View File
@@ -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;
}
+86 -27
View File
@@ -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 ------------------------------------------------ */
+6 -4
View File
@@ -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
+74 -1
View File
@@ -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 <math.h>
@@ -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();