From b2440e5107b5ce6ca0736bc5ef643df56f4b1f35 Mon Sep 17 00:00:00 2001 From: seedds Date: Tue, 27 Oct 2026 03:23:36 +0800 Subject: [PATCH] Make display-link tick tests deterministic in CI --- dm/gfx/tests/t_link_ticks.m | 66 ++++++++++++++++++++++++++---------- docs/reference/frame-rate.md | 2 +- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/dm/gfx/tests/t_link_ticks.m b/dm/gfx/tests/t_link_ticks.m index 1b722bf..0109082 100644 --- a/dm/gfx/tests/t_link_ticks.m +++ b/dm/gfx/tests/t_link_ticks.m @@ -1,9 +1,26 @@ // The acquire's waits on the display link, counted in its own ticks (mtl_link_wait_ticks) or in the game's 60 Hz vsyncs (mtl_link_wait_tick): a 120 Hz link takes two of its // ticks per vsync, so present_interval display at DM_PRESENT_HZ=120 needs the first to reach one frame per tick. The real acquire also covers fractional rates and the game/step -// pacing branches. A host thread stands in for the display link's callback; it runs faster than a real screen, and all expectations are in ticks, not wall-clock frame rates +// pacing branches. A controlled clock and timed wait deliver one tick per wake: a sleeping ticker can miss the acquire's real deadline on a busy CI runner +#import +#include +#include +#include +static uint64_t test_now = 10000000000ull; +static unsigned test_waits, test_spurious; +static int test_silent; +static int test_clock_gettime(clockid_t clock, struct timespec *t) { + (void)clock; + *t = (struct timespec){.tv_sec = test_now / 1000000000ull, .tv_nsec = test_now % 1000000000ull}; + return 0; +} +static int test_pthread_cond_timedwait(pthread_cond_t *, pthread_mutex_t *, const struct timespec *); +#define clock_gettime test_clock_gettime +#define pthread_cond_timedwait test_pthread_cond_timedwait #define nvn_game_fps nvn_game_fps_mtl_default #include "../nvn/nvn_mtl.m" #undef nvn_game_fps +#undef pthread_cond_timedwait +#undef clock_gettime #define g_ft g_ft_hle #include "../nvn/hle_nvn.c" #undef g_ft @@ -14,49 +31,54 @@ void svc_thread_dump(void) {} uint64_t now_ns(void) { return bp_now(); } static int checks, failed; #define CHECK(c, label) do { checks++; if (!(c)) { failed++; fprintf(stderr, "FAIL: %s\n", label); } } while (0) -static _Atomic int g_stop; -static void *ticker(void *a) { (void)a; while (!g_stop) { usleep(2000); pthread_mutex_lock(&g_link_mu); g_link_tick_n++; pthread_cond_broadcast(&g_link_cv); pthread_mutex_unlock(&g_link_mu); } return NULL; } -static uint64_t ticks_now(void) { pthread_mutex_lock(&g_link_mu); uint64_t n = g_link_tick_n; pthread_mutex_unlock(&g_link_mu); return n; } +// Only the OS wait is replaced: the production wait loop, timeout calculation, +// tick history and acquire conversion all run. No other thread advances time. +static int test_pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *deadline) { + if (cond != &g_link_cv || mutex != &g_link_mu) { fprintf(stderr, "unexpected timed wait\n"); abort(); } + test_waits++; + if (test_spurious) { test_spurious--; return 0; } + uint64_t end = (uint64_t)deadline->tv_sec * 1000000000ull + deadline->tv_nsec; + if (test_silent || test_now + 2000000ull >= end) { test_now = end; return ETIMEDOUT; } + test_now += 2000000ull; g_link_tick_n++; + return 0; +} // Ticks per wait over 50 waits of the given kind (frames that take no time: each wait is the whole frame) static double per_wait(int own, int n) { - for (int i = 0; i < 3; i++) own ? mtl_link_wait_ticks(n, 100000000ull) : mtl_link_wait_tick(n, 100000000ull); // settle on the tick - uint64_t t0 = ticks_now(); + uint64_t t0 = g_link_tick_n; for (int i = 0; i < 50; i++) own ? mtl_link_wait_ticks(n, 100000000ull) : mtl_link_wait_tick(n, 100000000ull); - return (ticks_now() - t0) / 50.0; + return (g_link_tick_n - t0) / 50.0; } // Exercise the real acquire so the conversion from the requested frame rate to link ticks is covered too. static double per_acquire(int hz, int display, int interval) { nvn_present_config(hz, display); win_t win = {.interval = interval}; int idx; - for (int i = 0; i < 5; i++) h_nvnWindowAcquireTexture((NVNwindow *)&win, NULL, &idx); - uint64_t t0 = ticks_now(); + uint64_t t0 = g_link_tick_n; for (int i = 0; i < 60; i++) h_nvnWindowAcquireTexture((NVNwindow *)&win, NULL, &idx); - return (ticks_now() - t0) / 60.0; + return (g_link_tick_n - t0) / 60.0; } static void acquire_ticks(int hz, int display, int interval, double want, const char *label) { double got = per_acquire(hz, display, interval); printf("%s: %.2f ticks per acquire (expected %.2f)\n", label, got, want); - CHECK(fabs(got - want) < 0.08, label); + CHECK(got == want, label); } int main(void) { @autoreleasepool { setenv("DM_RT", "0", 1); setenv("DM_PRESENT_SNAP", "0", 1); g_dm_present_link = 2; g_link = (CAMetalDisplayLink *)[NSObject new]; // a link is "on" while the object is set; nothing calls it here CHECK(mtl_link_active(), "the link counts as active"); - pthread_t th; pthread_create(&th, NULL, ticker, NULL); g_link_mode = 2; CHECK(mtl_link_rate() == 120, "a 120 Hz link reports 120"); double a = per_wait(0, 1), b = per_wait(1, 1), c = per_wait(1, 2); printf("120 Hz link: interval 1 waits %.2f ticks, one own tick %.2f, two own ticks %.2f\n", a, b, c); - CHECK(a > 1.8 && a < 2.2, "a 60 Hz vsync is two ticks of a 120 Hz link"); - CHECK(b > 0.8 && b < 1.2, "one own tick per frame: 120 frames a second"); - CHECK(c > 1.8 && c < 2.2, "two own ticks"); + CHECK(a == 2, "a 60 Hz vsync is two ticks of a 120 Hz link"); + CHECK(b == 1, "one own tick per frame: 120 frames a second"); + CHECK(c == 2, "two own ticks"); acquire_ticks(120, 1, 2, 1, "display 120 Hz ignores the game's interval"); acquire_ticks(90, 1, 1, 4. / 3, "display 90 Hz preserves fractional ticks"); acquire_ticks(75, 1, 1, 1.6, "display 75 Hz preserves fractional ticks"); acquire_ticks(120, 0, 2, 4, "game mode keeps interval 2 in 60 Hz vsyncs"); acquire_ticks(60, 1, 2, 2, "display 60 Hz ignores the game's interval"); acquire_ticks(240, 1, 1, 1, "a rate above the link's waits one tick"); - _Alignas(8) float state[2] = {60, 0}; dm_fps_state = (uint8_t *)state; + _Alignas(8) float state[10] = {60}; dm_fps_state = (uint8_t *)state; // include the fixed-step flag at +0x24 read by rt_period_ns acquire_ticks(120, 1, 1, 2, "the 60 fps frame step keeps 60 Hz vsyncs"); state[0] = 30; acquire_ticks(120, 1, 1, 4, "the 30 fps frame step keeps two 60 Hz vsyncs"); @@ -64,13 +86,21 @@ int main(void) { @autoreleasepool { g_link_mode = 1; CHECK(mtl_link_rate() == 60, "a 60 Hz link reports 60"); a = per_wait(0, 1); - CHECK(a > 0.8 && a < 1.2, "a 60 Hz link: one tick per vsync"); + CHECK(a == 1, "a 60 Hz link: one tick per vsync"); acquire_ticks(120, 1, 1, 1, "a 60 Hz link limits display 120 Hz to one tick"); g_link_mode = 3; CHECK(mtl_link_rate() == 30, "the 30 Hz power-saving link reports 30"); acquire_ticks(120, 1, 1, 1, "a 30 Hz link limits display 120 Hz to one tick"); acquire_ticks(60, 0, 2, 1, "a 30 Hz link keeps game interval 2 at one tick"); acquire_ticks(60, 0, 3, 2, "a 30 Hz link rounds game interval 3 up to two ticks"); - g_stop = 1; pthread_join(th, NULL); + unsigned waits = test_waits; g_link_tick_n += 4; + CHECK(mtl_link_wait_ticks(2, 10000000ull) && test_waits == waits, "already elapsed ticks need no wait"); + uint64_t tick = g_link_tick_n; waits = test_waits; test_spurious = 1; + CHECK(mtl_link_wait_ticks(2, 10000000ull) && g_link_tick_n - tick == 2 && test_waits - waits == 3, "a spurious wake cannot finish a tick wait"); + test_silent = 1; tick = g_link_tick_n; uint64_t start = test_now; + CHECK(!mtl_link_wait_ticks(1, 5000000ull), "a silent link times out"); + CHECK(g_link_tick_n == tick && test_now - start == 5000000ull, "timeout reaches its deadline without inventing ticks"); + test_silent = 0; + CHECK(mtl_link_wait_ticks(2, 10000000ull) && g_link_tick_n - tick == 2, "ticks resume from the last successful wait after timeout"); printf("t_link_ticks: %d checks, %d failed\n", checks, failed); return failed != 0; } } diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index ff73bc7..180d916 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -108,7 +108,7 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Cause:** the acquire waits for the present interval's vsyncs on the display link (`mtl_link_wait_tick`), counted in 60 Hz vsyncs, so two ticks of a 120 Hz link per frame; display mode's interval is 1. Dividing the link rate by the requested rate once per acquire loses fractional ticks (120 / 90 becomes 1). - **Fix:** in display mode above 60 Hz the acquire waits as many link ticks as one tick of the present rate takes (`link_wait` in `hle_nvn.c`, `mtl_link_wait_ticks`): one at 120 Hz. Fractional ticks carry across acquires: at 90 Hz, three frames wait four link ticks (1, 1, 2); a rate or pacing-mode change starts a new phase. Rates at or above the link's rate wait one tick. - **Found on:** Batman: Arkham Knight (`0100ACD0163D0000`; its profile and note are in a PR of their own): 119.8 fps in the opening cutscene on an M2 Max. -- **Check:** `t_link_ticks` (also run in CPU-only CI): with a stand-in for the display link's callback, a 60 Hz vsync waits two ticks of a 120 Hz link and one tick of a 60 Hz one, and a wait in the link's own ticks takes one tick per frame. The real acquire checks display pacing at 60, 75, 90, 120 and 240 Hz, game intervals and frame-step caps, including the 60 and 30 Hz link modes. These checks count ticks; they do not measure a game's frame rate on a screen. +- **Check:** `t_link_ticks` (also run in CPU-only CI): with a controlled clock and one tick delivered per timed wait, a 60 Hz vsync waits two ticks of a 120 Hz link and one tick of a 60 Hz one, and a wait in the link's own ticks takes one tick per frame. The real acquire checks display pacing at 60, 75, 90, 120 and 240 Hz, game intervals and frame-step caps, including the 60 and 30 Hz link modes. Exact tick counts, already elapsed ticks, spurious wakes and silent-link timeouts are checked without depending on OS scheduling: a sleeping ticker could miss the acquire's wall-clock timeout on CI. These checks do not measure a game's frame rate on a screen. ### Present snapshots: the game's swapchain texture free at once