Files
NX2navi/tests/frame_step_check.c
chrg6 328554e7c6 Run the sound manager's job at 60 Hz with whole steps above 60 fps.
The job (main+0x415300) hands the time manager's step to the sound system in its phase 0 and runs the sound objects'
and the system's updates in phases 1 and 2. With the set ostep that step is a fraction of a frame above 60 fps, and fed
fractions the game has been reported to keep sounds held after an area unloaded until no voice was free, new sounds
and then the music going silent until a restart. The new set osound (sound_steps, on by default) calls the page's
+0xf10 after the job's prologue: phase 0 adds the step to what the job is owed and runs once a whole step has built
up, phases 1 and 2 follow that choice, and a job that does not run gets phase 3, which returns through its own
epilogue. After phase 0's load of the step the page's +0xf00 makes a fraction one whole step. At 60 fps and below
the job runs every frame with the game's step. Both sites were checked against the module; frame_step_check covers
the gate at 60, 30, 144 and 240 fps. Needs the game code generated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 05:54:58 -05:00

347 lines
18 KiB
C

// 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; }
static int movie; // a movie plays (frame_rate.c movie_cap_hz)
int ov_movie_live(void) { return movie; }
int vk_fg_generating(void) { return 0; }
float dbg_speed_factor(void) { return 1.0f; } // the debug menu's game speed: normal
int vk_fg_active(void) { return 0; }
int vk_fg_wanted(void) { return 0; }
static int fg_mult = 1; // frames on screen per game frame
int vk_dlssg_multiplier(void) { return fg_mult; }
int vk_dlssg_active(void) { return fg_mult > 1; }
int vk_lowlat_on(void) { return 0; }
void rtss_limit_want(int64_t millihz) { (void)millihz; }
int rtss_limit_active(void) { return 0; }
// The time manager the sound job's gate reads its step from (frame_rate.c sound_job_step), while the sound checks run:
// [main+0x4c7e890] holds the slot, the slot the manager, the manager +0x10 the step. Nothing else reads.
static int fake_manager;
static float fake_step = 1.0f;
int plat_mem_read(uint64_t a, void *out, size_t n) {
const uint64_t slot = 0x7700000000ull, manager = 0x7700001000ull;
if (!fake_manager) return 0;
if (a == MOD_MAIN_BASE + 0x4c7e890 && n == 8) return memcpy(out, &slot, 8), 1;
if (a == slot && n == 8) return memcpy(out, &manager, 8), 1;
if (a == manager + 0x10 && n == 4) return memcpy(out, &fake_step, 4), 1;
return 0;
}
int plat_mem_readable(uint64_t a, size_t n) { (void)a, (void)n; return 0; }
void mem_kernel_access(uint64_t a, uint64_t n) { (void)a, (void)n; }
int mem_host_readable(uint64_t a, uint64_t n) { (void)a, (void)n; return 0; }
int mem_info(uint32_t id, uint64_t *out) { (void)id, (void)out; return 0; } // the floor probe (off here)
// the floor scan (off here)
static RtGuestLayout layout_stub;
const RtGuestLayout *mem_layout(void) { return &layout_stub; }
uint32_t mem_query(uint64_t a, RtMemoryInfo *out) { (void)a, memset(out, 0, sizeof *out); return 1; }
int mem_read_hold(uint64_t a, uint64_t n) { (void)a, (void)n; return 0; }
void mem_read_release(void) {}
int input_pad_stick_percent(double pct[2]) { pct[0] = pct[1] = 0; return 0; }
int mem_copy_readable(uint8_t *const *dst, const uint64_t (*src)[2], int n, uint8_t *ok) {
(void)dst, (void)src, (void)n, (void)ok;
return 0;
}
uint64_t kern_now_ns(void) { return 0; }
void plat_mutex_lock(PlatMutex *m) { (void)m; } // one thread here
void plat_mutex_unlock(PlatMutex *m) { (void)m; }
// ---- 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;
uint64_t plat_now_ns(void) { return now_ns; }
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;
}
// ---- the patch sites that cut the step to a whole number, called as the lifted code calls them: the value in s0, the
// object in its register, the whole number back in the register the cut wrote (all 64 bits of it).
static Cpu cpu;
static uint32_t water(uint64_t object, float step) { // frame_rate_water_host: x21, w9
uint32_t bits;
memcpy(&bits, &step, 4);
cpu.v[0] = bits, cpu.x[21] = object, cpu.x[9] = ~0ull;
frame_rate_water_host(&cpu);
CHECK(cpu.x[9] >> 32 == 0, "the water's steps leave x9's upper half %#llx", (unsigned long long)cpu.x[9]);
return (uint32_t)cpu.x[9];
}
static uint32_t day(uint64_t object, float secs) { // frame_rate_day_host: x19, w8
uint32_t bits;
memcpy(&bits, &secs, 4);
cpu.v[0] = bits, cpu.x[19] = object, cpu.x[8] = ~0ull;
frame_rate_day_host(&cpu);
return (uint32_t)cpu.x[8];
}
// frame_rate_sim_host as the surfaces' sites call it (set owater): after the site's division, the update's seconds over
// 1/60 s in s0 and the object in x19; then the game's own cut of s0 (fcvtzs) gives the steps. Only s0's low lane may
// change: every general register, the other lanes and vector registers and the flags stay as they were.
static uint32_t sim(uint64_t object, float seconds) {
Cpu c, before;
uint8_t *p = (uint8_t *)&c;
for (size_t i = 0; i < sizeof c; i++) p[i] = (uint8_t)(i * 37u + 11u);
float quotient = seconds / (1.0f / 60.0f); // the site's fdiv s0, s8, s0 (the literal is 1/60 as a float)
uint32_t bits;
memcpy(&bits, &quotient, 4);
c.v[0] = bits, c.x[19] = object; // the division wrote s0 and zeroed the lanes above it
before = c;
frame_rate_sim_host(&c);
int same = c.nzcv == before.nzcv && c.sp == before.sp && !memcmp(&c.v[1], &before.v[1], sizeof c.v - sizeof c.v[0]) &&
c.v[0] >> 32 == 0 && !memcmp(c.x, before.x, sizeof c.x);
CHECK(same, "the surfaces' site changed something besides s0's low lane");
memcpy(&quotient, &c.v[0], 4);
return cut_fcvtzs(quotient); // the game's fcvtzs after the site
}
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 movie started at 240 fps: exactly one frame from its first update, late and short frames included, as the game
// shows one picture per update; at its end the step follows the frame limit again within 12 frames.
set_limit("unlimited");
for (int i = 0; i < 300; i++) present(p240);
movie = 1;
for (int i = 0; i < 600; i++) {
float g = present(i % 120 == 7 ? 0.070 : i % 120 == 8 ? 0.004 : p60);
CHECK(g == 1.0f, "movie, frame %d: %.6f frames", i, g);
}
CHECK(frame_step_movie_hz() == 60, "the movie's rate: %d", frame_step_movie_hz());
movie = 0;
float after = 0;
for (int i = 0; i < 12; i++) after = present(p240);
CHECK(fabsf(after - 0.25f) < 0.002f, "after the movie: %.4f frames", after);
// 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);
}
// The limit counts the frames the game draws: frame generation's multiplier leaves the pace of a numeric limit and
// of unlimited alone, and divides only the monitor's rate (144 Hz here), which is a rate on the screen.
static const struct { const char *l; int gen; double hz, game_hz; } caps[] = {
{"60", 1, 60, 60}, {"60", 4, 60, 60}, {"120", 2, 120, 120}, {"240", 4, 240, 240},
{"display", 1, 144, 144}, {"display", 4, 36, 36}, {"unlimited", 1, 240, 0}, {"unlimited", 4, 240, 0}};
for (unsigned i = 0; i < sizeof caps / sizeof *caps; i++) {
set_limit(caps[i].l);
fg_mult = caps[i].gen;
double hz = 1e9 / (double)frame_step_cap_ns(), game = frame_step_game_hz();
CHECK(fabs(hz - caps[i].hz) < 0.01, "limit %s at %dx paces the game at %.3f fps, not %.0f", caps[i].l, caps[i].gen,
hz, caps[i].hz);
CHECK(fabs(game - caps[i].game_hz) < 0.01, "limit %s at %dx reports %.3f game fps, not %.0f", caps[i].l,
caps[i].gen, game, caps[i].game_hz);
}
fg_mult = 1;
// The water's ripples at 144 fps, a step of 60/144 frame: 64 water objects asked once a frame each run 600 steps
// in 1440 frames (the cut gave none), and the day's seconds asked between them keep their own fraction.
{
const float step = 60.0f / 144.0f;
const uint64_t first = 0x7100000000ull, clock = 0x7200000000ull;
uint32_t steps[64] = {0}, secs = 0;
for (int f = 0; f < 1440; f++) {
for (int k = 0; k < 64; k++) steps[k] += water(first + 0x1000 * (uint64_t)k, step);
secs += day(clock, 2 * step);
}
for (int k = 0; k < 64; k++)
CHECK(steps[k] >= 599 && steps[k] <= 600, "water object %d of 64 at 144 fps: %u steps in 1440 frames", k, steps[k]);
CHECK(secs >= 1199 && secs <= 1200, "the day beside 64 water objects: %u seconds in 1440 frames", secs);
}
// A step of one frame, and of two, is what the game computes, also after a fraction was left; a quarter frame
// gives one step every fourth frame, and what fcvtzs cuts to nothing or saturates leaves the remainder alone.
{
const uint64_t pond = 0x7300000000ull;
for (int i = 0; i < 3; i++) CHECK(water(pond, 0.25f) == 0, "a quarter frame, call %d", i);
CHECK(water(pond, 0.0f) == 0 && water(pond, -3.5f) == 0xfffffffdu && water(pond, NAN) == 0 &&
water(pond, 3e9f) == 0x7fffffffu, "zero, a negative step, NaN and a step too large are cut as fcvtzs cuts");
CHECK(water(pond, 0.25f) == 1, "the fourth quarter frame");
CHECK(water(pond, 0.5f) == 0, "half a frame");
for (int i = 0; i < 60; i++) CHECK(water(pond, 1.0f) == 1, "one frame, call %d", i);
for (int i = 0; i < 30; i++) CHECK(water(pond, 2.0f) == 2, "two frames, call %d", i);
CHECK(water(pond, 0.5f) == 1, "the half frame kept through the whole ones");
}
// The water managers and the floors (set owater): at 120, 144 and 240 fps each runs 60 fixed steps a second (the cut
// gave none above 60), beside 64 water objects; at 60 and 30 fps one and two steps an update, as the game counts them.
{
const uint64_t interactive = 0x7400000000ull, responsive = 0x7400001000ull, floor = 0x7400002000ull,
first = 0x7500000000ull;
const float rates[] = {144.0f, 240.0f, 120.0f};
for (int r = 0; r < 3; r++) {
uint32_t a = 0, b = 0, c = 0, w = 0;
int frames = (int)(rates[r] * 10);
for (int f = 0; f < frames; f++) {
a += sim(interactive, 1.0f / rates[r]);
for (int k = 0; k < 64; k++) w += water(first + 0x1000 * (uint64_t)k, 60.0f / rates[r]);
b += sim(responsive, 1.0f / rates[r]);
c += sim(floor, 1.0f / rates[r]);
}
CHECK(a >= 599 && a <= 600 && b >= 599 && b <= 600 && c >= 599 && c <= 600,
"%.0f fps: the managers and the floor ran %u, %u and %u steps in 10 s",
rates[r], a, b, c);
CHECK(w >= 64 * 599, "%.0f fps: the water objects beside the managers ran %u steps", rates[r], w);
}
for (int i = 0; i < 120; i++) CHECK(sim(interactive, 1.0f / 60.0f) == 1, "60 fps, update %d", i);
for (int i = 0; i < 60; i++) CHECK(sim(responsive, 1.0f / 30.0f) == 2, "30 fps, update %d", i);
CHECK(sim(interactive, 0.0f) == 0 && sim(interactive, -1.0f / 60.0f) == 0xffffffffu &&
sim(interactive, NAN) == 0, "no time, negative time and NaN are cut as fcvtzs cuts");
CHECK(sim(interactive, 1.0f / 15.0f) == 4, "four steps for a 15 fps update (the game's clamp to 3 follows)");
}
// A place with many interactive floors: 2 water managers and 30 floors, each asking once an update at 144 fps, all
// keep their own remainder and run 60 fixed steps a second (with 8 slots they took each other's and ran none).
{
const uint64_t first = 0x7600000000ull;
uint32_t steps[32] = {0};
for (int f = 0; f < 1440; f++)
for (int k = 0; k < 32; k++) steps[k] += sim(first + 0x1000 * (uint64_t)k, 1.0f / 144.0f);
for (int k = 0; k < 32; k++)
CHECK(steps[k] >= 599 && steps[k] <= 600, "surface %d of 32 at 144 fps: %u steps in 10 s", k, steps[k]);
}
// The sound job (set osound). At 60 and 30 fps every phase of every frame runs with the game's step; at 144 and 240
// fps it runs 60 times a second with a step of one, phases 1 and 2 with phase 0; a skipped phase gets 3 in w2.
{
fake_manager = 1;
const float steps[] = {1.0f, 2.0f, 60.0f / 144.0f, 0.25f};
const int frames[] = {600, 300, 1440, 2400}; // 10 s each
for (int r = 0; r < 4; r++) {
fake_step = steps[r];
int ran[3] = {0}, odd = 0;
float fed = 0;
for (int f = 0; f < frames[r]; f++) {
for (uint32_t phase = 0; phase < 3; phase++) {
cpu.x[2] = phase;
frame_rate_sound_gate_host(&cpu);
if (cpu.x[2] == phase) ran[phase]++;
else odd += cpu.x[2] != 3;
if (phase == 0 && cpu.x[2] == 0) { // phase 0 runs on to its load of the step
uint32_t bits;
memcpy(&bits, &fake_step, 4);
cpu.v[0] = bits;
frame_rate_sound_step_host(&cpu);
float s;
memcpy(&s, &cpu.v[0], 4);
fed += s;
CHECK(cpu.v[0] >> 32 == 0, "the step's upper lanes stay zero");
}
}
}
const int want = steps[r] >= 1.0f ? frames[r] : 600;
CHECK(ran[0] >= want - 1 && ran[0] <= want && ran[1] == ran[0] && ran[2] == ran[0] && !odd,
"step %.3f: phases ran %d, %d, %d of %d frames (want %d)", steps[r], ran[0], ran[1], ran[2], frames[r],
want);
CHECK(fed >= 599.0f && fed <= 600.5f, "step %.3f: the sound system was fed %.2f frames in 10 s", steps[r], fed);
}
// Owed fractions survive a whole step, and a step that is not positive runs and clears them.
SoundGate g = {0.0f, 1};
CHECK(!sound_gate_take(&g, 0, 0.5f) && !sound_gate_take(&g, 1, 1.0f), "half a frame: the job and its phase 1 wait");
CHECK(sound_gate_take(&g, 0, 1.0f) && sound_gate_take(&g, 2, 1.0f) && g.owed == 0.5f, "a whole frame runs, the half kept");
CHECK(sound_gate_take(&g, 0, 0.5f) && g.owed == 0.0f, "the kept half and a new one run");
CHECK(!sound_gate_take(&g, 0, 0.75f) && sound_gate_take(&g, 0, NAN) && g.owed == 0.0f &&
sound_gate_take(&g, 0, 0.0f), "NaN and 0 run as the game has them and clear what is owed");
fake_manager = 0;
}
printf("frame_step_check: %s\n", fails ? "FAILED" : "ok");
return fails != 0;
}