Describe RivaTuner Statistics Server's hand-off, the pacer's fallbacks and rtss-profile.txt as they behave now.

docs/architecture.md said the pacer holds nothing while RTSS holds the limit, through movies and minimised windows,
that the pacer stands aside once RTSS lists the process, that RTSS fails at 1.2 times the limit over six polls, and
that a killed run leaves its limit for the next run to take as the player's. The one-limiter and RTSS paragraphs now
name the pacer's fallbacks (before the first host present, a movie, 200 ms with no host present, a limiter seen
letting frames through, any failure) and its 240 ceiling beside RTSS, the settle rule, a movie's profile, the proof
RTSS must give before the pacer stands aside, the 1.05 rule over three new periods, the rounding down, and the state
file with rtss_limit_crash. nx2navi.ini.example's rtss lines, README.md's frame generation item, docs/building.md's
rtss_limit_check and frame_step_check paragraphs and rtss_limit.c's header follow, and the comments at main.c
crash_handler's and dispatch.c fatal_end's rtss_limit_crash say when RTSS's library is still called there (no line in
the state file, never on RTSS's own thread). No code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 19:19:28 -05:00
parent 60694532a1
commit 80b96a748d
8 changed files with 67 additions and 39 deletions

View File

@@ -38,8 +38,10 @@ Report problems as issues, following [CONTRIBUTING.md](CONTRIBUTING.md).
display; in a window of another shape than the picture, frames are generated inside the picture only, so the black
bars stay clean. The frame limit counts the frames the game draws, and frame generation adds its
frames on top of it. The frame limit can also be
handed to RivaTuner Statistics Server, which then limits the game in its place; where frame generation or low
latency keeps it out, the game says when a limit set in RivaTuner itself holds the game as well
handed to RivaTuner Statistics Server, which then limits the game in its place: one limiter at a time, and the
game takes the limit back while a movie plays, while it is minimised and whenever RivaTuner is not seen holding it;
where frame generation or low latency keeps it out, the game says when a limit set in RivaTuner itself holds the
game as well
- HDR on HDR displays, two kinds: the game's scene tone mapped to the display's peak, matched to the game's mid grey and tone, with true blacks, or the game's own native HDR; peak, game and HUD brightness in the menu
- The game's known shaders prepared when the game is built, and the pipelines met in play built again in the
background at each start, so play rarely stops to compile them; a shader or pipeline met for the first time can

View File

@@ -257,11 +257,15 @@ keeps per-core data without locks and relies on that, so the host models it (`ru
3%.
- One limiter is the goal: the driver's low latency (`vk_lowlat.c`), RivaTuner Statistics Server (`rtss_limit.c`) or
the game's pacer holds the frame limit, and the log's "frame rate:" and "lowlat:" lines name the one in charge.
Frame generation keeps RTSS out (it would count the generated frames), and the game's pacer holds the drawn frames
beside it. The exceptions as they stand: `pace_smooth` still waits on the present thread while RTSS holds the limit;
DLSS Frame Generation's Reflex sleeps where the frame starts while the pacer holds the limit; while RTSS holds it the
pacer holds nothing at all, through movies and while no picture goes out (a minimised window, `frame_hold`) too; and
a limit set in RTSS itself still holds the game where the game keeps RTSS out, which the log ("two limiters") and the
The pacer is the fallback wherever the other limiter's hold is not proven: before the first host present, while a
movie plays (the pacer keeps the movie's slots, which no other limiter knows), once no picture went out for 200 ms (a
minimised window, `frame_hold`), for the rest of the run once the frames show the other limiter letting them through
faster than the limit, and after any failure. While RTSS holds the limit the pacer keeps only the 240-frame ceiling
the game's code needs (`rtss_decide.h` `rtss_pace_period`). Frame generation keeps RTSS out (it would count the
generated frames), and the game's pacer holds the drawn frames beside it. The exceptions as they stand: `pace_smooth`
still waits on the present thread while RTSS holds the limit; DLSS Frame Generation's Reflex sleeps where the frame
starts while the pacer holds the limit; and a limit set in RTSS itself still holds the game where the game keeps RTSS
out, which the log ("two limiters") and the
frame rate row of the menu point out, and the same where RTSS is out of it for the run because its profile cannot be
written or its hooks library does not load, which the log points out the same way (the menu shows the administrator
note for the first and nothing for the second).
@@ -269,19 +273,29 @@ keeps per-core data without locks and relies on that, so the host models it (`ru
first present starts a thread that looks RTSS up (`InstallDir` under `HKLM\SOFTWARE\WOW6432Node\Unwinder\RTSS`,
`RTSSHooks64.dll`) and reads its shared memory (`RTSSSharedMemoryV2`) every 500 ms. While RTSS runs, the limit goes
into RTSS's profile for the executable (`Profiles\<exe>.cfg`, through the hooks library's profile calls, read back
from the saved file; a fractional rate over a denominator where RTSS knows one, else rounded to whole frames), and
once RTSS lists the process the pacer stands aside (`rtss_limit_active`). The pacer keeps the limit while RTSS does
not run or has not hooked the game, for a limit RTSS refuses, and for the rest of the run when the profile cannot be
written (no administrator rights), the hooks library does not load, or RTSS's own frame count of the game stays
above 1.2 times the limit for six polls; the menu names the first and the last of these. Frame generation, the
from the saved file; a fractional rate over a denominator where RTSS knows one, else rounded down to whole frames)
once the first picture went out and the limit stayed the same for two polls, so a want that flips (low latency at a
swapchain rebuild) writes nothing. A movie leaves the play limit in the profile; only a play limit below the movie's
rate gives the profile no limit for the movie's length, so that RTSS does not space out the frames the pacer sends at
once to catch up. The pacer stands aside (`rtss_limit_active`) only once RTSS is seen holding the game: it lists the
process with its Vulkan presents, has read the profile (its update flag clear) and measured a period of the game's
frames lately, no movie plays and a picture went out within 200 ms. The pacer keeps the limit while RTSS does not run
or has not hooked the game, for a limit RTSS refuses, and for the rest of the run when the profile cannot be written
(no administrator rights), the hooks library does not load, or RTSS's own frame count of the game is above 1.05
times the limit in three new periods in a row (a period RTSS no longer moves counts neither way); the menu names the
first and the last of these. Frame generation, the
driver's low latency and `NX2NAVI_RTSS=0` keep RTSS out (`RTSS_OUT_*`): the profile gets back what it held before
the run changed it, and while RTSS is kept out its thread does not load the hooks library and only reads RTSS's own
settings for the game; a later limit (frame generation or low latency switched off) loads it and hands RTSS the limit
as usual, and `NX2NAVI_RTSS=0` keeps the thread read-only for the run. While RTSS lists the game the log names those
settings (limit, frame time limit, scanline sync, display sync, Reflex sleep, passive wait) once per change. Every
orderly exit puts the profile back as it was before the run (`rtss_limit_restore`), and no want reaches it after
that; a kill, the device-lost watchdog or a crash inside the crash handler leaves the game's limit in the profile,
and the next run takes it for the player's own.
that. What the profile held is also kept in `rtss-profile.txt` next to the executable, one line per profile, written
before the run's first change of it and removed once it is back: a start that finds a line left by a run that did
not close (a kill, a hang, the device-lost watchdog, a crash or a fatal error, whose ends make no call into RTSS's
library: `rtss_limit_crash`) puts that profile back first, unless the player changed it since. A file that cannot be
written leaves the run as before, with a line in the log; profiles that versions before the file left with the
game's limit cannot be told from the player's own.
- Measuring pacing: `NX2NAVI_TRACE=present` logs a line per picture (the host present's interval, the game's present
interval, the wait for the slot and how far past it the present went), `NX2NAVI_TRACE=perf` each wait's share of the
frame ("bound:"), and `tools/perf_summary.py` condenses both. Both measure before the host present, so a wait RTSS

View File

@@ -469,7 +469,9 @@ A change to the step Ocarina is given each frame (`runtime/frame_rate.c` `ocarin
game's counters get out of it (`whole_take`, `frame_rate_day_host`, `frame_rate_water_host`, `frame_rate_sim_host`), 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. It calls the two hooks as their patch
frame, uneven frame times one steady step, and game time follows the clock. While RivaTuner Statistics Server holds
the limit the pacer keeps the 240 ceiling, the limit's own period once no host present went out for 200 ms, and a
movie's rate. It calls the two hooks as their patch
sites do: at 144 fps each of 64 water objects gets its ripple steps and the day its seconds, at 120, 144 and 240 fps
the water managers and floors of the set `owater` get 60 fixed steps a second with only s0 changed, and a step of one
frame gives what the game computes. It also checks that each of those sites in `recomp/patches-v111.txt` ends in one
@@ -483,13 +485,20 @@ velocity at the time scales where the game cuts it at 60 fps and nowhere else. N
A change to how the frame limit goes to RivaTuner Statistics Server (`runtime/rtss_limit.c`, `runtime/rtss_decide.h`)
passes `python tests\rtss_limit_check.py`. It builds `rtss_decide.h` alone and checks its reader of RTSS's shared
memory against a fake `RTSSSharedMemoryV2` (a bad header, short entries, an entry past the memory or past the 4096th
find nothing) and the worker's decisions poll by poll against a fake profile: RTSS's injection delay, RTSS quitting and
coming back, a read-only profile, a refused limit, the frame count rule, kept-out wants and their reasons, low latency
switching at every poll, a movie, a closed run, a hooks library that does not load, and two limiters. On Windows it
also builds the real `rtss_limit.c` against a fake RTSS (its registry key, hooks library, profile files and shared
memory) and runs each scenario in a process of its own, among them a want after the exit's restore and one the worker
read before it, a worker polling without pause while the want switches, and the registry and file calls a present
must not make. No GPU, no RTSS, no game files.
find nothing; the flags and the period where RTSS keeps them) and the worker's decisions poll by poll against a fake
profile and a fake RTSS clock: RTSS's injection delay through listed with no API, the profile not read yet and no
period measured, RTSS quitting and coming back, a read-only profile, a refused limit, the frame count rule (1.05 times
the limit over three new periods, a frozen period never counted), no picture going out, kept-out wants and their
reasons, a want that must stay two polls and low latency switching at every poll, a movie at 144 and at 40, a closed
run, a hooks library that does not load, and two limiters. It also checks the pacer's period beside RTSS
(`rtss_pace_period`), the limit's rounding without a denominator, and the state file `rtss-profile.txt`: its lines,
its rewrite and what a start does with a line a run that did not close left. Statically, a crash and a fatal error
(`main.c` `crash_handler`, `dispatch.c` `fatal_end`) go through `rtss_limit_crash`. On Windows it also builds the real
`rtss_limit.c` against a fake RTSS (its registry key, hooks library, profile files and shared memory) and runs each
scenario in a process of its own, among them a want after the exit's restore and one the worker read before it, a
worker polling without pause while the want switches, the registry and file calls a present must not make, and a run
killed while RTSS held its limit followed by the next start, each in a process of its own. No GPU, no RTSS, no game
files.
A change to how the mixer applies volumes (`runtime/audio_mixer.c` `accumulate_ramp`, the voice and splitter paths
of `audio_mixer_render`) or to what it owes the device after a stall (`ring_pull`, `audio_mixer_owed`) passes

View File

@@ -24,14 +24,15 @@
# game draws the monitor's rate divided by the multiplier.
# fps_limit = 60
# Where RivaTuner Statistics Server is installed and running, the limit goes into its profile for the game's
# executable and RTSS does the limiting instead of the game; closing the game puts back what the profile held.
# Changing that profile needs the game run as administrator (RTSS keeps its profiles under Program Files). Without it
# the game limits by itself, as it does with frame generation on (RTSS would count the generated frames) and for the
# rest of the run when RTSS's own frame counter shows the game well past the limit (RTSS then hooks the game without
# holding it back); with low latency on, the driver holds the limit. rtss = 0 always leaves it to the game. With
# rtss = 0, frame generation or low latency, a limit set in RTSS itself (its global profile or the game's, a frame time
# limit or scanline sync) limits the game a second time: the log and the settings menu say so, and setting it to 0 in
# RTSS leaves one limiter.
# executable and RTSS does the limiting instead of the game, once RTSS is seen holding the game to it; closing the game
# puts back what the profile held, and after a crash or a kill the next start does (rtss-profile.txt next to the game
# keeps what it held). Changing that profile needs the game run as administrator (RTSS keeps its profiles under Program
# Files). Without it the game limits by itself, as it does with frame generation on (RTSS would count the generated
# frames), while a movie plays or the window is minimised, and for the rest of the run when RTSS's own frame counter
# shows the game past the limit (RTSS then hooks the game without holding it back); with low latency on, the driver
# holds the limit. rtss = 0 always leaves it to the game. With rtss = 0, frame generation or low latency, a limit set in
# RTSS itself (its global profile or the game's, a frame time limit or scanline sync) limits the game a second time:
# the log and the settings menu say so, and setting it to 0 in RTSS leaves one limiter.
# rtss = 1
# 1: asks RTSS's EnumProfiles whether the game's profile exists, instead of looking for its file. EnumProfiles crashes
# the game (RTSS keeps a pointer of the game's in 32 bits); only for testing an RTSS that fixed it.

View File

@@ -123,8 +123,8 @@ static void fatal_end(int code, const char *fmt, va_list ap) {
fs_save_crash_wait(); // a save the game already reported as done finishes first (bounded)
prof_sampler_write(); // NX2NAVI_PROFILE: what was sampled until now, else a crash loses the whole profile
// RivaTuner Statistics Server's profile goes back at the next start, from the line the run keeps for it in
// rtss-profile.txt (rtss_limit.c rtss_limit_crash, as in main.c crash_handler): no call into RTSS's library here,
// from any thread
// rtss-profile.txt (rtss_limit.c rtss_limit_crash, as in main.c crash_handler), with no call into RTSS's library
// here; only where that line could not be written is it put back now, and never from RTSS's own thread
rtss_limit_crash();
rt_rumble_stop_bounded(); // a controller on HD rumble: nothing renews or stops its vibration after the end
// "frametime: at a fatal error" and the stutter map, the session's last period: after what a kill of the process

View File

@@ -278,10 +278,10 @@ double frame_step_game_hz(void) {
// frames only to LIMIT_FREE_MAX_HZ, the ceiling the game's code needs, so the two never stack, and it takes the limit
// back once no picture went out for 200 ms (rtss_decide.h rtss_pace_period): a minimised window or a frame hold leaves
// RTSS no present to hold. While a movie plays the pacer holds the movie's rate and keeps its slots whatever RTSS does.
// NX2NAVI_RTSS=0 and frame generation (RTSS would count the generated frames) keep it
// out, and so does a driver's low latency while it is in effect (vk_lowlat.c): the driver holds the limit where the
// frame starts, and RTSS would hold the finished picture, the wait that mode is there to remove. RTSS gets the reason
// (RTSS_OUT_*), for the log and for its check of a limit set in RTSS itself.
// NX2NAVI_RTSS=0 and frame generation (RTSS would count the generated frames) keep it out, and so does a driver's low
// latency while it is in effect (vk_lowlat.c): the driver holds the limit where the frame starts, and RTSS would hold
// the finished picture, the wait that mode is there to remove. RTSS gets the reason (RTSS_OUT_*), for the log and for
// its check of a limit set in RTSS itself.
static double paced_hz; // the game frames a second the newest frame_step_cap_ns paces at (ocarina_step); 0: none
static int movie_paced; // the movie rate the newest frame_step_cap_ns paces at (ocarina_step, frame_step_movie_hz); 0: none
int frame_step_movie_hz(void) { return movie_paced; }

View File

@@ -437,7 +437,8 @@ static void crash_handler(const PlatCrash *x) {
kern_free_cores_note();
fs_save_crash_wait(); // a save the game already reported as done finishes first (bounded; fs_host.c)
// after the report: RivaTuner Statistics Server's profile goes back at the next start, from the line the run keeps
// for it (rtss_limit.c rtss_limit_crash): no call into RTSS's library from a process that failed
// for it in rtss-profile.txt (rtss_limit.c rtss_limit_crash), with no call into RTSS's library from a process that
// failed; only where that line could not be written is it put back here, and never from RTSS's own thread
rtss_limit_crash();
rt_rumble_stop_bounded(); // a controller on HD rumble stops (dispatch.c: 300 ms at most, SDL may be the crash)
fflush(stderr);

View File

@@ -1,6 +1,7 @@
// RivaTuner Statistics Server as the frame limiter (frame_rate.c frame_step_cap_ns): where RTSS is installed and running,
// the frame limit goes into RTSS's profile for this executable and the game's own pacer holds no frames, so there is
// one limiter. Anywhere else, or when any step fails, the game's own limiter works as it always did.
// the frame limit goes into RTSS's profile for this executable and, while RTSS is seen holding the game to it, the
// game's own pacer keeps only the 240-frame ceiling (rtss_decide.h rtss_pace_period), so there is one limiter.
// Anywhere else, or when any step fails, the game's own limiter works as it always did.
//
// RTSS's hooks library (RTSSHooks64.dll in the folder its installer records under
// HKLM\SOFTWARE\WOW6432Node\Unwinder\RTSS, InstallDir) exports the profile calls, loaded here at run time: