diff --git a/docs/building.md b/docs/building.md index 7126091c..1a729b03 100644 --- a/docs/building.md +++ b/docs/building.md @@ -244,6 +244,11 @@ launcher imports a zip it wrote. The second builds `fs_host.c` and `save_share.c and runs each step as a start of the game on a profile it makes: the export, an import from a zip the launcher exported, its backup and swap at the next start, the zips it refuses and Cancel. No game files. +A change to the step Ocarina is given each frame (`runtime/frame_rate.c` `ocarina_step`) passes +`python tests\frame_step_check.py`, which builds `frame_rate.c` with the rest of the runtime stubbed and feeds it +display times: a held 60 fps limit gives exactly one frame every frame, a 240 fps limit never less than a quarter +frame, uneven frame times one steady step, and game time follows the clock. No game files. + A change to the button prompts (`tools/make_romfs_patches.py --prompts`, `tools/button_icons.py`) passes `.venv-py314\Scripts\python tests\romfs_prompts_test.py`, which runs the tool on a RomFS it makes itself (no game files); a change to the settings passes `nx2navi.exe --probe-cfg`. diff --git a/nx2navi.ini.example b/nx2navi.ini.example index 5ee3445c..1c77e8f0 100644 --- a/nx2navi.ini.example +++ b/nx2navi.ini.example @@ -36,6 +36,14 @@ # cloth_realtime = 1 # Fix for physics that bounce at high frame rates. # highfps_fix = 1 +# Above 60 fps the game's physics solved contacts and friction in fewer steps a second than at 60, and in none at all +# in a frame shorter than 1/240 s, so things standing on moving platforms were left behind. 1 rounds the step count +# up instead; 0 is the game's own count. +# physics_steps = 1 +# The step the game is given each frame: 1 keeps it steady (the same value from frame to frame while the frame rate +# holds, exactly one 60 Hz frame at a 60 fps limit) and makes up for a late frame over the next second; 0 is the mean +# of the last five frame times, which changes with every frame. +# step_steady = 1 [performance] # The keys of this section and of [diagnostics] are for testing and troubleshooting, and take effect at the next start. diff --git a/recomp/patches-v111.txt b/recomp/patches-v111.txt index 8c33c433..98b54a1a 100644 --- a/recomp/patches-v111.txt +++ b/recomp/patches-v111.txt @@ -18,6 +18,7 @@ set wide_movie NX2NAVI_PATCH_WIDE_MOVIE 0 set res_fixed NX2NAVI_PATCH_RES_FIXED 0 set ostep NX2NAVI_OCARINA_REALTIME 1 set zstd NX2NAVI_ZSTD_NATIVE 1 +set osolver NX2NAVI_PHYSICS_STEPS 1 # Real-time step: the framework's per-frame update (main+0x6e6be0) gives the time manager **[main+0x4c7e890] its # nominal frame through main+0x3b4e300 (+0x10 frames per update, +0x14 the inverse, +0x18 seconds, +0x1c the rate), @@ -28,6 +29,25 @@ set zstd NX2NAVI_ZSTD_NATIVE 1 site ostep main 6e6cc0 1e220c21 @page:x10 # fcsel s1, s1, s2, eq -> adrp x10, page site ostep main 6e6cc4 1e201828 bd410148 # fdiv s8, s1, s0 -> ldr s8, [x10, #256] +# Physics solver steps: the physics system's step setup (main+0x38aeb80, and the same code in main+0x38afc10) gives the +# rigid body world its solver step count for the frame, trunc(frames per update * count + 0.001) with the count at +# system +0x244 (4, set by the constructor main+0x38ae800), and the world keeps the result's inverse beside it (world +# vtable +0x88, main+0x3a24c20: world +0xc48 and +0xcb8). That is 4 at 60 fps and 8 at 30, the two rates the game ships +# with: 240 solver steps a second. Below one frame per update it truncates to fewer a second (3 a frame from 61 to 80 +# fps, 2 to 120, 1 to 240: 144 a second at 144 fps), and to 0 under a quarter frame, with an infinite inverse. The +# solver (main+0x180b0a0) runs its solve and sub-integration loop that many times and skips it at 0 (main+0x180b380): +# such a frame solves no contact or friction and moves no rigid body, so whatever stands on a moving body is left +# behind in it. At a 240 fps limit that was the frame after a late one, whose display interval is shorter than the +# period (runtime/frame_rate.c ocarina_step). The count rounds up instead, ceil(frames per update * count - 0.25): 4 and 8 as before at 60 and 30 +# fps, also with a step a few percent off them, never fewer steps a second than at 60 fps, and at least 1 down to a +# 16th of a frame. s2 is loaded only for this sum, and none of the words reads or writes the flags. +site osolver main 38aebe4 bd440d02 1e2a1002 # ldr s2, [x8, #0x40c] (0.001) -> fmov s2, #0.25 +site osolver main 38aebf0 1f010800 1f218800 # fmadd s0, s0, s1, s2 -> fnmsub s0, s0, s1, s2 (s0 * s1 - s2) +site osolver main 38aebf4 1e380001 1e280001 # fcvtzs w1, s0 -> fcvtps w1, s0 (round up) +site osolver main 38afd10 bd440d02 1e2a1002 # the same three words in the second copy +site osolver main 38afd1c 1f010800 1f218800 +site osolver main 38afd20 1e380001 1e280001 + # Game image: the graphics setup (main+0x1202ce0) fills the renderer's configuration with the size of the picture the # game presents, 2560x1440 at every console mode (config +0x14, copied to renderer +0x9c, which main+0x346be50 reads # for the main render target set, main+0xf49040), and the display settings object (main+0x31f3890) stores the same size diff --git a/runtime/cfg.c b/runtime/cfg.c index c2b024b2..c2f7b5ad 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -31,7 +31,7 @@ static int save_ok = 1; // the last cfg_save_to wrote (o // Every name the runtime reads (and the patch sets in generated/), without the prefix. A file key outside this list is // logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line. static const char *const known[] = { - "CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c) + "CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c) "AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CHANNELS", "AUDIO_DEVICE", "AUDIO_GAME_PRIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "AUDIO_STATS", "BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT", "BIND_LSTICK_RIGHT", "BIND_LSTICK_UP", "BIND_MINUS", @@ -74,6 +74,7 @@ static const char *const known[] = { static const struct { const char *key, *section; } sections[] = { {"FPS_LIMIT", "frame rate"}, {"FPS60", "frame rate"}, {"RTSS", "frame rate"}, {"REALTIME", "frame rate"}, {"CLOTH_REALTIME", "frame rate"}, {"HIGHFPS_FIX", "frame rate"}, + {"PHYSICS_STEPS", "frame rate"}, {"STEP_STEADY", "frame rate"}, {"FULLSCREEN", "display"}, {"WINDOW_SIZE", "display"}, {"PERF_OVERLAY", "display"}, {"VK_PRESENT", "display"}, {"GPU_INDEX", "display"}, {"MONITOR", "display"}, {"OUTPUT_RES", "display"}, {"GAME_ASPECT", "display"}, {"GAME_RES", "display"}, {"RENDER_SCALE", "display"}, {"UPSCALER", "display"}, {"SHARPNESS", "display"}, {"ASPECT", "display"}, {"FOV", "display"}, {"ANISO", "display"}, diff --git a/runtime/frame_rate.c b/runtime/frame_rate.c index 2f7228c7..b2953b2e 100644 --- a/runtime/frame_rate.c +++ b/runtime/frame_rate.c @@ -233,6 +233,7 @@ double frame_step_game_hz(void) { // RivaTuner Statistics Server (rtss_limit.c) gets the same limit; while it limits this process the pacer holds no // frames, so the two never stack. NX2NAVI_RTSS=0 and frame generation (RTSS would count the generated frames) keep it out. +static double paced_hz; // the game frames a second the newest frame_step_cap_ns paces at (ocarina_step); 0: none uint64_t frame_step_cap_ns(void) { frame_limit_live(); static int rtss_allowed = -1; @@ -253,6 +254,7 @@ uint64_t frame_step_cap_ns(void) { } if (limit_hz == -2 && !movie_hz && !frame_free_running()) { // unlimited: the pacer does not hold frames, and rtss_limit_want(rtss ? 0 : -1); // RTSS's limit is cleared + paced_hz = 0; return 0; } // unlimited in a build that steps by the clock: LIMIT_FREE_MAX_HZ game frames; during a movie, the movie's rate. @@ -276,6 +278,7 @@ uint64_t frame_step_cap_ns(void) { // display's rate, the generated frames then fill the display's refreshes and none is dropped. if (gen > 1) hz /= gen; if (movie_hz) hz = movie_hz; + paced_hz = hz; rtss_limit_want(rtss ? (int64_t)(hz * 1000 + 0.5) : -1); if (rtss_limit_active()) return 0; return (uint64_t)(1e9 / hz + 0.5); @@ -343,13 +346,87 @@ static double ocarina_step_s(void) { return base * speed / 60.0; } -// Free-running builds (Ocarina): the time manager's base step follows the display interval (5-frame average, 1/1000 to -// 1/15 s, in 60 Hz frames), written at each present. The game left it at one 60 Hz frame whatever the frame rate, so at -// 144 fps it ran 2.4 times too fast. NX2NAVI_OCARINA_STEP=0 leaves it. Counts how often the game itself had changed -// the value since the last write (diagnosis). +// Free-running builds (Ocarina): the game's step follows the display interval (ocarina_step, in 60 Hz frames), written +// at each present: frames per update on the patch page with the set ostep, else the time manager's base step. The +// game left it at one 60 Hz frame whatever the frame rate, so at 144 fps it ran 2.4 times too fast. +// NX2NAVI_OCARINA_STEP=0 leaves it. Counts how often the game itself had changed the value since the last write +// (diagnosis). static uint64_t ostep_writes, ostep_changed; static float ostep_last = -1; +// The step Ocarina is given for a display interval dt, in seconds. The game ships with a step of one 60 Hz frame (two +// at 30 fps) that never changes in play, so nothing in it was written for a step that differs from frame to frame: its +// physics takes the step as the length of the next solve while the bodies' velocities come from the step before +// (the physics system keeps both, main+0x38af1f0), and it derives whole numbers from it (the solver step count, +// patches-v111.txt osolver). The mean of the last 5 intervals, which stood here, moved with every one of them: display +// intervals of 5 and 30 ms in turn gave steps of 15 and 20 ms in turn, and one 60 ms frame at 200 fps took the step +// from 5 to 16 ms for five frames and back. So the step is kept steady and the clock is kept by a slow correction: +// - the base is the mean of the last 12 intervals without their longest and shortest: one late frame and the short +// one after it (nvn_api_obj.c pace keeps the phase) leave it alone, intervals alternating in a period of 2, 3 or 4 +// frames give one value, and a real change of frame rate arrives within 12 frames; +// - a base within 3% of the pacer's period is that period: at a frame limit the game holds, the step is the same +// number every frame, at 60 fps exactly the one frame the game ships with; +// - what the display showed and the game was not given is owed (the late frame cut from the base, the 3% of the +// snap). Past half a step of it the step repays a 64th a frame, 10% of the step at most, so the game still follows +// the clock and a hitch is paid back over the next second rather than in five frames. More than STEP_RESET_S owed +// is dropped: the game runs slower than the clock, as it does for a frame longer than STEP_MAX_S. +// The step is at least OSTEP_MIN_S: the solver step count of the set osolver reaches 0 under a 16th of a frame. +// NX2NAVI_STEP_STEADY=0 gives the mean of the last 5 intervals again (comparison runs). +#define OSTEP_WINDOW 12 +#define OSTEP_SNAP 0.03 +#define OSTEP_REPAY_MAX 0.10 +#define OSTEP_MIN_S (1.0 / 480.0) +static double os_ring[OSTEP_WINDOW], os_owed; +static unsigned os_count, os_next; +static double os_step_lo, os_step_hi; // the steps given since the last speed line (speed_report) + +static void ocarina_step_reset(void) { + os_count = os_next = 0; + os_owed = 0; +} + +static double ocarina_step(double dt) { + static int steady = -1; + if (steady < 0) { + const char *e = cfg_get("NX2NAVI_STEP_STEADY"); + steady = !(e && !strcmp(e, "0")); + } + if (dt > STEP_MAX_S) dt = STEP_MAX_S; + os_ring[os_next++ % OSTEP_WINDOW] = dt; + if (os_count < OSTEP_WINDOW) os_count++; + double step; + if (!steady) { + unsigned n = os_count < STEP_SAMPLES ? os_count : STEP_SAMPLES; + double sum = 0; + for (unsigned i = 1; i <= n; i++) sum += os_ring[(os_next - i) % OSTEP_WINDOW]; + step = sum / n; + if (step < 1.0 / 1000) step = 1.0 / 1000; + } else { + double sum = 0, lo = os_ring[0], hi = os_ring[0]; + for (unsigned i = 0; i < os_count; i++) { + sum += os_ring[i]; + if (os_ring[i] < lo) lo = os_ring[i]; + if (os_ring[i] > hi) hi = os_ring[i]; + } + double base = os_count >= 4 ? (sum - lo - hi) / (os_count - 2) : sum / os_count; + double period = paced_hz > 0 ? 1.0 / paced_hz : 0; + if (period > 0 && fabs(base - period) <= OSTEP_SNAP * period) base = period; + double over = fabs(os_owed) - base * 0.5, repay = 0; + if (over > 0) { + repay = over / 64 < OSTEP_REPAY_MAX * base ? over / 64 : OSTEP_REPAY_MAX * base; + if (os_owed < 0) repay = -repay; + } + step = base + repay; + if (step < OSTEP_MIN_S) step = OSTEP_MIN_S; + if (step > STEP_MAX_S) step = STEP_MAX_S; + os_owed += dt - step; + if (os_owed > STEP_RESET_S) os_owed = STEP_RESET_S; + if (os_owed < -STEP_RESET_S) os_owed = -STEP_RESET_S; + } + if (os_step_lo <= 0 || step < os_step_lo) os_step_lo = step; + if (step > os_step_hi) os_step_hi = step; + return step; +} static void ocarina_step_write(double dt) { static int on = -1; @@ -423,7 +500,7 @@ static void speed_report(uint64_t now, double step, int interval) { probe = e && !strcmp(e, "1"); } double wall = (double)(now - win_t0) * 1e-9; - char pos[160] = ""; + char pos[240] = ""; float p[3]; if (probe && player_pos(p)) { double d = last_pos_ns ? hypot(p[0] - last_pos[0], p[2] - last_pos[2]) : 0, @@ -433,6 +510,12 @@ static void speed_report(uint64_t now, double step, int interval) { memcpy(last_pos, p, sizeof p); last_pos_ns = now; } + if (os_step_hi > 0) { // Ocarina: the shortest and longest step given in these frames, and the time still owed + size_t o = strlen(pos); + snprintf(pos + o, sizeof pos - o, "; step %.3f..%.3f frames, %.1f ms owed", os_step_lo * 60, os_step_hi * 60, + os_owed * 1000); + os_step_lo = os_step_hi = 0; + } if (step_word || win_game > 0) rt_log("fps: %llu frames in %.3f s (%.1f fps), %.3f s game stepped, speed %.3f; interval 1/2/3+ %llu/%llu/%llu%s", (unsigned long long)win_frames, wall, (double)win_frames / wall, win_game, win_game / wall, @@ -488,14 +571,9 @@ void frame_step_present(uint64_t display_ns, int interval) { step_store((float)step); } } - if (frame_free_running() && last_ns && dt > 0 && dt < STEP_RESET_S) { - static double os[STEP_SAMPLES]; - static unsigned on_, ox; - os[ox++ % STEP_SAMPLES] = dt; - if (on_ < STEP_SAMPLES) on_++; - double sum = 0; - for (unsigned i = 0; i < on_; i++) sum += os[i]; - ocarina_step_write(sum / on_); + if (frame_free_running() && last_ns) { + if (dt > 0 && dt < STEP_RESET_S) ocarina_step_write(ocarina_step(dt)); + else ocarina_step_reset(); // a load or a debugger stop: the intervals before it say nothing about the next } last_ns = display_ns; // The game clock of untimed script lines: what the game stepped this frame, the patch page's step with the diff --git a/tests/frame_step_check.c b/tests/frame_step_check.c new file mode 100644 index 00000000..fa6e503e --- /dev/null +++ b/tests/frame_step_check.c @@ -0,0 +1,140 @@ +// runtime/frame_rate.c: the step Ocarina is given (ocarina_step), driven through frame_step_cap_ns and +// frame_step_present with made-up display times and the rest of the runtime stubbed. Built and run by +// tests/frame_step_check.py; prints each failed check and exits 1 on any. With the argument "mean" it runs with +// NX2NAVI_STEP_STEADY=0 and checks the mean of the last five intervals. +#include "frame_rate.c" + +static int fails; +#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL line %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +// ---- the runtime frame_rate.c reaches +static uint8_t page[4096]; +static const char *limit = "60", *steady = "1"; +static unsigned version = 1; +const int rt_game_version = 111; +unsigned rt_trace; +uint64_t rt_guest_start_ns; +const char *cfg_get(const char *key) { + if (!strcmp(key, "NX2NAVI_FPS_LIMIT")) return limit; + if (!strcmp(key, "NX2NAVI_STEP_STEADY")) return steady; + if (!strcmp(key, "NX2NAVI_RTSS")) return "0"; + return NULL; +} +unsigned cfg_version(void) { return version; } +int rt_patch_set(const char *name) { return !strcmp(name, "ostep") ? 0 : -1; } +int rt_patch_enabled(int set) { return set == 0; } +void rt_patch_disable(int set) { (void)set; } +void rt_patch_switch(int set, int on) { (void)set, (void)on; } +void *rt_patch_page(void) { return page; } +void rt_log(const char *fmt, ...) { (void)fmt; } +double gfx_window_refresh_hz(void) { return 144; } +int ov_movie_live(void) { return 0; } +int vk_fg_generating(void) { return 0; } +int vk_fg_active(void) { return 0; } +int vk_fg_wanted(void) { return 0; } +int vk_dlssg_multiplier(void) { return 1; } +int vk_dlssg_active(void) { return 0; } +void rtss_limit_want(int64_t millihz) { (void)millihz; } +int rtss_limit_active(void) { return 0; } +int plat_mem_read(uint64_t a, void *out, size_t n) { (void)a, (void)out, (void)n; return 0; } +int plat_mem_readable(uint64_t a, size_t n) { (void)a, (void)n; return 0; } +uint64_t kern_now_ns(void) { return 0; } + +// ---- one present: the pacer asks for the period, then reports the display time. Returns the step in 60 Hz frames. +static uint64_t now_ns = 1000000000ull; +static double wall_s, game_s; +static float present(double interval_s) { + frame_step_cap_ns(); + now_ns += (uint64_t)(interval_s * 1e9 + 0.5); + frame_step_present(now_ns, 1); + float f; + memcpy(&f, page + 256, 4); + wall_s += interval_s; + game_s += f / 60.0; + return f; +} +static void set_limit(const char *l) { + limit = l; + version++; + present(1.0); // a gap: the filter starts again + wall_s = game_s = 0; +} + +int main(int argc, char **argv) { + int mean = argc > 1 && !strcmp(argv[1], "mean"); + if (mean) steady = "0"; + frame_step_init(); + float one; + memcpy(&one, page + 256, 4); + CHECK(one == 1.0f, "the page holds one frame before the first present, not %g", one); + const double p60 = 16666667e-9, p240 = 4166667e-9; + + if (mean) { // the comparison setting: 5 and 30 ms in turn give 15 and 20 ms in turn + set_limit("unlimited"); + float lo = 9, hi = 0; + for (int i = 0; i < 200; i++) { + float f = present(i & 1 ? 0.030 : 0.005); + if (i >= 20) lo = f < lo ? f : lo, hi = f > hi ? f : hi; + } + CHECK(fabsf(lo - 0.9f) < 1e-3f && fabsf(hi - 1.2f) < 1e-3f, "mean of five: %g..%g frames, not 0.9..1.2", lo, hi); + printf("frame_step_check (mean): %s\n", fails ? "FAILED" : "ok"); + return fails != 0; + } + + // A 60 fps limit with a late frame and the short one after it every 37 frames: one frame exactly, every frame. + set_limit("60"); + for (int i = 0; i < 600; i++) { + float f = present(i % 37 == 5 ? p60 + 0.004 : i % 37 == 6 ? p60 - 0.004 : p60); + CHECK(f == 1.0f, "60 fps limit, frame %d: %.9g frames", i, f); + } + CHECK(fabs(game_s - wall_s) < 0.001, "60 fps limit: %.4f s stepped in %.4f s", game_s, wall_s); + + // The 240 cap with late frames: never under a quarter frame (the game's own solver step count is 0 there). + set_limit("240"); + for (int i = 0; i < 1200; i++) { + double late = 0.0005 + 0.0025 * ((i * 7919) % 100) / 100.0; + float f = present(i % 29 == 3 ? p240 + late : i % 29 == 4 ? p240 - 0.0005 - 0.0025 * (((i - 1) * 7919) % 100) / 100.0 : p240); + CHECK(f >= 0.25f && f < 0.2525f, "240 cap, frame %d: %.6f frames", i, f); + } + CHECK(fabs(game_s - wall_s) < 0.003, "240 cap: %.4f s stepped in %.4f s", game_s, wall_s); + + // Unlimited, 5 and 30 ms in turn: one step, and the clock kept. + set_limit("unlimited"); + float prev = 0; + for (int i = 0; i < 600; i++) { + float f = present(i & 1 ? 0.030 : 0.005); + if (i >= 24) CHECK(fabsf(f / prev - 1) < 0.02f, "5/30 ms, frame %d: %.4f after %.4f frames", i, f, prev); + prev = f; + } + CHECK(fabs(game_s / wall_s - 1) < 0.005, "5/30 ms: %.4f s stepped in %.4f s", game_s, wall_s); + + // 200 fps and one 60 ms frame: the step rises by a tenth at most and the 55 ms are paid back. + set_limit("unlimited"); + for (int i = 0; i < 1000; i++) { + float f = present(i == 300 ? 0.060 : 0.005); + if (i >= 24) CHECK(f >= 0.2999f && f <= 0.3301f, "hitch, frame %d: %.4f frames", i, f); + if (i == 302) CHECK(f > 0.32f, "hitch: the step does not repay (%.4f frames two frames later)", f); + } + CHECK(fabs(game_s - wall_s) < 0.004, "hitch: %.4f s stepped in %.4f s", game_s, wall_s); + + // A real change of rate, 240 to 120 fps, arrives within 12 frames. + set_limit("unlimited"); + for (int i = 0; i < 300; i++) present(p240); + float f = 0; + for (int i = 0; i < 12; i++) f = present(2 * p240); + CHECK(f > 0.49f, "240 -> 120 fps: %.4f frames after 12", f); + + // A frame longer than 1/15 s slows the game down; the step never leaves 1/480..1/15 s. + set_limit("unlimited"); + for (int i = 0; i < 100; i++) { + float g = present(0.2); + CHECK(g <= 4.0001f, "long frames: %.4f frames", g); + } + set_limit("unlimited"); + for (int i = 0; i < 100; i++) { + float g = present(0.0011); // 900 fps, as a quick menu without the pacer would run + CHECK(g >= 0.125f, "short frames: %.4f frames", g); + } + printf("frame_step_check: %s\n", fails ? "FAILED" : "ok"); + return fails != 0; +} diff --git a/tests/frame_step_check.py b/tests/frame_step_check.py new file mode 100644 index 00000000..d77ec53a --- /dev/null +++ b/tests/frame_step_check.py @@ -0,0 +1,46 @@ +"""Build and run tests/frame_step_check.c: the step runtime/frame_rate.c gives Ocarina, with the rest of the runtime +stubbed. No game files: the module layout the runtime includes is a stand-in written to the test's build folder. + +Usage: frame_step_check.py +""" +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS_BIN = Path(r'C:\msys64\ucrt64\bin') + + +def compiler(): + if os.environ.get('CC'): + return os.environ['CC'] + if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists(): + return str(MSYS_BIN / 'clang.exe') + return shutil.which('clang') or 'clang' + +LAYOUT = '''// A stand-in for the generated layout.h (tests/frame_step_check.py). +#pragma once +#define MOD_MAIN_BASE 0x7000000000ull +#define MOD_MAIN_SIZE 0x4ea0000ull +''' + + +def main(): + work = ROOT / 'build' / 'test' / 'frame_step' + work.mkdir(parents=True, exist_ok=True) + (work / 'layout.h').write_text(LAYOUT) + exe = work / ('frame_step_check.exe' if os.name == 'nt' else 'frame_step_check') + env = dict(os.environ) + if os.name == 'nt' and MSYS_BIN.exists(): + env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') + subprocess.check_call([compiler(), '-O1', '-Wall', '-Wno-unused-function', '-fno-strict-aliasing', + '-I', str(ROOT / 'runtime'), '-I', str(work), + str(ROOT / 'tests' / 'frame_step_check.c'), '-o', str(exe), '-lm'], env=env) + rc = subprocess.run([str(exe)], env=env).returncode + return subprocess.run([str(exe), 'mean'], env=env).returncode or rc + + +if __name__ == '__main__': + sys.exit(main())