Round the physics solver's step count up above 60 fps and give Ocarina a steady step.

Above 60 fps players fell off moving platforms. The physics system takes its solver step count for a frame as
trunc(frames per update * 4 + 0.001): 4 at 60 fps and 8 at 30, the rates the game ships with, but 3 from 61 to 80
fps, 2 to 120, 1 to 240 (144 solver steps a second at 144 fps against 240 at 60), and 0 in a frame shorter than a
quarter of a 60 Hz frame. The solver skips its whole solve and sub-integration loop at 0, so such a frame resolves no
contact or friction and moves no rigid body. At a 240 fps limit, which "unlimited" paces at too, that was the frame
after every late one: the pacer keeps its phase, so the next display interval is shorter than the period.
The new patch set osolver rounds the count up, ceil(frames per update * 4 - 0.25): 4 and 8 as before, never fewer
steps a second than at 60 fps, at least 1. physics_steps = 0 gives the game's own count.

The step itself was the mean of the last five display intervals and changed with every frame: 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. The game never saw a changing step on the console. The step is now the mean of the last 12 intervals without
the longest and the shortest, is the pacer's period when within 3% of it (at a 60 fps limit exactly one frame, every
frame), and repays the time the game was not given at up to a tenth of the step, so game time still follows the
clock. It is at least 1/480 s. step_steady = 0 gives the mean of five again. With trace = perf the fps lines show the
shortest and longest step and the time owed.

tests/frame_step_check.py drives frame_rate.c with made-up display times and the rest of the runtime stubbed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-09 22:54:46 -05:00
parent 6994563613
commit 4cbdecb64b
7 changed files with 312 additions and 14 deletions

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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

View File

@@ -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"},

View File

@@ -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

140
tests/frame_step_check.c Normal file
View File

@@ -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;
}

46
tests/frame_step_check.py Normal file
View File

@@ -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())