Files
NX2navi/tests/stutter_hitch_check.c
chrg6 c040ca6c5f Write the frame-time summary and the stutter map at a fatal error too, the map only when its lock is free.
dispatch.c fatal_end wrote only the profiler's samples, so a run that ended in a FATAL had no "frametime" or
stutter-map lines for its last period: the numbers that would show what the game was doing before it failed.

perf.c perf_summary_flush_try (weak in dispatch.c for builds without perf.c) runs after fs_save_crash_wait: ft_stop,
then the running period's "frametime: at a fatal error" lines and the stutter map. The failing thread may hold any
lock, so stutter_map.c gains stutter_report_as(tag, try_only), which only tries sm_lock and writes nothing when it is
taken (stutter_report keeps the blocking take); the back end's exit lines (gfx_exit_log) and the trace's segments
(seg_close) are left out, as they read state the failure may have left half made. The crash report keeps no summary:
its state may be damaged.

Not reproduced before the change (a missing diagnostic). tests/fatal_exit_check.py now wants the summary at
rt_fatal and checks statically that perf_summary_flush_try only tries the map's lock and leaves the back end out;
tests/stutter_hitch_check.c gets the trylock stand-in it needs. fatal_exit_check, stutter_hitch_check,
stutter_map_check and the compile checks of every runtime file pass.

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

141 lines
6.9 KiB
C

// tests/stutter_hitch_check.py builds this: runtime/stutter_map.c's hitch rule on made-up frame times, with the few
// runtime functions it calls stubbed. A steady change of pace (the game drawing at 60 fps after 200) counts no hitch;
// isolated long frames each count one; a frame over SM_HITCH_SURE_NS counts whatever follows it.
#include <stdarg.h>
#include <stdio.h>
#include "../runtime/stutter_map.c"
static uint64_t clock_ns;
int cfg_bool(const char *name) { return !strcmp(name, "NX2NAVI_STUTTER_MAP"); }
uint64_t kern_now_ns(void) { return clock_ns; }
void plat_mutex_lock(PlatMutex *m) { (void)m; }
void plat_mutex_unlock(PlatMutex *m) { (void)m; }
int plat_mutex_trylock(PlatMutex *m) { (void)m; return 1; }
static int logged;
static char head[1400]; // the last report's first line
void rt_log(const char *fmt, ...) {
logged++;
char line[1400];
va_list ap;
va_start(ap, fmt);
vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
if (!strncmp(line, "stutter map (", 13)) memcpy(head, line, sizeof head);
}
// perf.c's causes, in its order (FT_C_*); the frames below are charged to the last, unattributed
static const char *const names[] = {"pipeline", "translation", "texture", "fence", "pacing", "hand-off", "replay",
"census", "report", "unattributed"};
#define OTHER (SM_CAUSES - 1)
_Static_assert(sizeof names / sizeof *names == SM_CAUSES, "names follows perf.c's FT_C_* list");
static uint64_t frame_no;
static void frames(int n, double ms) {
uint64_t part[SM_CAUSES] = {0};
for (int i = 0; i < n; i++) {
uint64_t d = (uint64_t)(ms * 1e6);
clock_ns += d;
part[OTHER] = d;
stutter_frame(++frame_no, clock_ns, d, part, names, SM_CAUSES, OTHER);
}
}
static int fails;
static void expect(const char *what, uint64_t got, uint64_t want) {
if (got != want) printf("FAIL %s: %llu, expected %llu\n", what, (unsigned long long)got, (unsigned long long)want), fails++;
}
// The hitches in play the exit's report prints, and the ones the areas hold (what a report counts so far).
static uint64_t exit_hitches(void) {
head[0] = 0;
stutter_report("exit");
const char *p = strstr(head, " areas; ");
return p ? strtoull(p + 8, NULL, 10) : UINT64_MAX;
}
static uint64_t counted(void) {
uint64_t n = 0;
for (int i = 0; i < n_areas; i++) n += areas[i].hitch;
return n;
}
int main(void) {
stutter_map_init();
clock_ns = 10000000000ull; // well past any area load
frames(200, 5.0);
expect("steady 200 fps", areas[0].hitch, 0);
frames(300, 16.6); // the game's menus at 60 fps
expect("a change to 60 fps", areas[0].hitch, 0);
if (!paced_out) printf("FAIL the change of pace was not counted as one\n"), fails++;
frames(200, 7.0);
for (int k = 0; k < 5; k++) frames(1, 30.0), frames(60, 7.0); // five isolated spikes
expect("five isolated spikes", areas[0].hitch, 5);
frames(1, 150.0), frames(1, 150.0), frames(60, 7.0); // two long frames in a row: one episode of 300 ms
expect("two frames over the sure line", areas[0].hitch, 6);
expect("their episode's time", areas[0].worst_ns, 300000000ull);
frames(1, 40.0), frames(1, 40.0), frames(60, 7.0); // a double spike: one episode
expect("a double spike", areas[0].hitch, 7);
// three long frames, each followed by one as long, as after an area load in a session of 2026-11-13 (87, 91 and
// 96 ms): one episode, not three changes of pace
uint64_t paced0 = paced_out, charged0 = areas[0].cause_ns[OTHER];
frames(1, 87.0), frames(1, 91.0), frames(1, 96.0), frames(60, 7.0);
expect("three slow frames in a row", areas[0].hitch, 8);
expect("not a change of pace", paced_out, paced0);
expect("the episode's time", areas[0].cause_ns[OTHER] - charged0, 274000000ull);
// a change to 30 fps from 140: a run of long frames longer than SM_EPISODE_MAX, a change of pace
frames(200, 7.0);
frames(300, 33.3);
expect("a change to 30 fps", areas[0].hitch, 8);
frames(200, 7.0);
stutter_area("Kakariko"); // a load: what follows within SM_LOAD_NS is the load's
frames(1, 60.0), frames(30, 7.0);
expect("a spike right after a load (play)", areas[1].hitch, 0);
expect("a spike right after a load (load)", areas[1].load_hitch, 1);
clock_ns += 10000000000ull;
frames(60, 7.0);
stutter_load(); // the texture pool's load of an area, without the game's
frames(1, 60.0), frames(30, 7.0);
expect("a spike right after the pool's load (play)", areas[1].hitch, 0);
expect("a spike right after the pool's load (load)", areas[1].load_hitch, 2);
// At 30 fps every frame is over SM_HITCH_ANY_NS and so a candidate: a hitch followed by the steady frames ends at
// itself, not at the end of the slow run (an episode of 301 frames, 10 s long, before)
stutter_area("Lake Hylia");
clock_ns += 10000000000ull;
frames(200, 7.0);
frames(1, 150.0), frames(300, 33.3);
expect("150 ms then 300 frames at 30 fps", areas[2].hitch, 1);
expect("150 ms then 300 frames at 30 fps (worst)", areas[2].worst_ns, 150000000ull);
frames(200, 7.0); // the end of the slow run stands out from the frames after it: one more
stutter_area("Gerudo Valley");
clock_ns += 10000000000ull;
frames(200, 33.3); // a steady 30 fps with a 120 ms hitch every 60 frames: each counts
for (int k = 0; k < 5; k++) frames(1, 120.0), frames(59, 33.3);
expect("five hitches at 30 fps", areas[3].hitch, 5);
expect("five hitches at 30 fps (worst)", areas[3].worst_ns, 120000000ull);
expect("five hitches at 30 fps (their time)", areas[3].cause_ns[OTHER], 600000000ull);
// The exit's report counts a hitch in the last frames, which only the next frames would have decided: a frame over
// the sure line followed by 0, 1 or 2 frames (a waiting candidate, then an open episode), two such frames in a row
// (one episode) and three long frames ending the run (one episode); each time what the run counts once it goes on
stutter_area("Death Mountain");
clock_ns += 10000000000ull;
static const double tail[][3] = {{150.0, 0, 0}, {150.0, 7.0, 0}, {150.0, 7.0, 7.0}, {150.0, 150.0, 0},
{150.0, 150.0, 7.0}, {87.0, 91.0, 96.0}};
for (size_t t = 0; t < sizeof tail / sizeof *tail; t++) {
frames(100, 7.0);
uint64_t before = counted();
for (int k = 0; k < 3 && tail[t][k] > 0; k++) frames(1, tail[t][k]);
char what[64];
snprintf(what, sizeof what, "the exit right after case %zu", t);
expect(what, exit_hitches(), before + 1);
frames(60, 7.0);
snprintf(what, sizeof what, "case %zu once the run went on", t);
expect(what, counted(), before + 1);
}
frames(100, 7.0);
expect("the exit with nothing waiting", exit_hitches(), counted());
if (!logged) printf("FAIL nothing was logged\n"), fails++;
if (fails) return 1;
printf("stutter_hitch_check: all checks passed\n");
return 0;
}