Files
NX2navi/runtime/rtss_limit.c
chrg6 da822abec3 Keep the state file's line ahead of RivaTuner Statistics Server's profile, and keep what a run owes the profile after a put back that did not land.
The review found three ways rtss-profile.txt could fall behind the profile. Every write after a run's first one saved
the profile before the line, so a kill between the two left a line whose value the profile no longer held, and the
next start took the game's limit for the player's (rtss_restore_plan LEAVE). A put back whose write did not land kept
the line, but rtss_put_back_done cleared S.changed, so a later limit took the run's first-write path again and read the
game's own limit from the profile as the value to put back, overwriting the true one in the line. And put_back removed
the line after DeleteProfile without checking that the profile went away.

The line now carries the value the run wrote before the newest one (rtss_decide.h RtssSaved prev_limit and prev_den,
rtss1 with two more fields; the file came with this branch and was never released). rtss_limit.c apply writes the new
value into the line before the profile (record_before) and drops the previous one once the profile holds the new one,
or returns to it when RTSS refused it (record_after), and rtss_restore_plan takes a profile holding either value for
the game's. A new owed flag keeps had_profile, old_limit and old_denominator from the run's first write until a put
back lands: a later first write keeps them and the line's values, a failed first write leaves the line alone, and
rtss_limit_restore tries the put back again at the exit. put_back and restore_left check that a deleted profile is
gone before they remove the line, and restore_left's lines name the value the profile held. tests/rtss_limit_check.c
covers the new fields and the plan for either value; tests/rtss_worker_check.c checks the line before and after a
write (state_record), a run killed inside the save of its second limit and the next start removing the profile
(kill_restart_mid_write), a put back that did not land followed by a limit and the exit (put_back_owed), and a removal
that did not land (put_back_undeleted). Lines over 120 columns from the earlier fixes are wrapped.

Not reproduced before the change. Checks that passed: tests/rtss_limit_check.py (44 of 44), tests/exit_order_check.py,
tests/fatal_exit_check.py, pass_cc_check.py --wall on rtss_limit.c, tools/check_content.py --staged.

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

635 lines
36 KiB
C

// 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, 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:
// LoadProfile(name) reads a profile (the global one for a name without its own), Get/SetProfileProperty read and change
// the loaded copy, SaveProfile(name) writes it to Profiles\<name>.cfg, UpdateProfiles makes every hooked process
// read its profile again, EnumProfiles lists the profiles that exist (profile_exists says why it is not called),
// DeleteProfile removes one. "FramerateLimit" is the limit in frames per second (0: none); "FramerateLimitDenominator"
// divides it, so 59940 over 1000 limits to 59.94 (a monitor's measured rate). SaveProfile reports nothing: RTSS's
// Profiles folder sits under Program Files, which a process without administrator rights cannot write, and the save
// then fails silently. So every change is read back
// from the saved profile, and a limit that did not stick leaves the game's own limiter in charge for the session.
//
// RTSS runs when its shared memory "RTSSSharedMemoryV2" carries the signature 'RTSS'. The game's own limiter stands
// aside only once RTSS is seen to hold the game (rtss_decide.h rtss_decide): the memory's application list holds this
// process id with its Vulkan presents (RTSS hooks a process some seconds after it starts, its injection delay), RTSS
// has read the profile the game wrote, it measured a period of the game's frames lately, no movie plays and pictures
// go out. Until then, and again whenever one of these ends (RTSS quits, the window is minimised), the game's own limiter
// paces the frames. While RTSS limits, each new period RTSS measures for the game is checked against the limit: above
// 1.05 times it for three periods in a row, and the game takes the limiting back for the rest of the run. So it does
// when RTSS lists the game with the limit for 30 s while pictures go out and never shows its hold: RTSS may then hold
// the frames beside the game's limiter.
//
// The calls run on a thread of their own, every half second, which also looks RTSS up (find_rtss), so a present never
// waits on the registry or profile files. On every orderly exit (rtss_limit_restore) the profile gets back what it held
// before the game changed it: the old limit, or no profile of its own when there was none; from then on no want reaches
// it and the game's own limiter paces the frames left. What it held is also kept in rtss-profile.txt next to the
// executable (rtss_decide.h RtssSaved), written before the run's first change of the profile and removed once the
// profile is back: an exit that skips the restore (a kill, a hang, the device-lost watchdog) leaves the line, and the
// next start puts the profile back from it before it changes anything (left_start, restore_left), unless the player
// changed the profile since; a start with NX2NAVI_RTSS=0 only logs it. A crash and a fatal error leave it to that start
// too (rtss_limit_crash), with no call into RTSS's library from the failing process.
//
// NX2NAVI_RTSS=0 keeps RTSS out of it. Frame generation keeps it out too (RTSS counts every presented frame, the
// generated ones included), and so does a driver's low latency (vk_lowlat.c), which holds the frames where they start.
// While RTSS is kept out the thread does not load the hooks library and only reads RTSS's own settings for the game, to
// tell when they limit it a second time (watch), except once at the start, to put back a profile that a run which did
// not close left with its limit (left_start); 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.
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "gfx.h"
#include "host.h"
#include "platform/platform.h"
#include "rtss_decide.h"
#ifdef _WIN32
#include <windows.h>
typedef DWORD (*EnumProfilesFn)(LPSTR list, DWORD size);
typedef void (*ProfileFn)(LPCSTR name);
typedef BOOL (*PropertyFn)(LPCSTR property, LPBYTE data, DWORD size);
typedef void (*UpdateFn)(void);
#define POLL_MS 500
static PlatMutex lock = PLAT_MUTEX_INIT; // the hooks calls and the state below
static _Atomic int64_t want = RTSS_UNSET; // millihertz from the pacer: >0 a limit, 0 none; RTSS_OUT_* kept out
static _Atomic int64_t active_for = RTSS_UNSET; // the want RTSS holds the game to now; RTSS_UNSET none
static _Atomic int64_t movie_mhz; // a movie's rate while one plays (rtss_limit_movie), 0 none
static _Atomic int failed; // RTSS_FAIL_*: RTSS is out of it for the rest of the run (S.fail)
static _Atomic int closed; // rtss_limit_restore ran: nothing reaches the profile any more
static _Atomic int two; // kept out, and RTSS limits the game by a setting of its own (watch)
static _Atomic int started; // rtss_limit_want spawned the worker
static _Atomic int ready; // the worker found RTSS: dll_path, rtss_dir and profile hold their values
static char dll_path[MAX_PATH], profile[MAX_PATH], rtss_dir[MAX_PATH];
static HMODULE dll;
static EnumProfilesFn enum_profiles;
static ProfileFn load_profile, save_profile, delete_profile;
static PropertyFn get_property, set_property;
static UpdateFn update_profiles;
static RtssState S = RTSS_STATE_INIT; // the worker's decisions (rtss_decide.h), under lock
static int has_denominator; // this RTSS knows FramerateLimitDenominator
static int had_profile; // the profile existed before this run changed it
static DWORD old_limit, old_denominator = 1;
static int owed; // this run changed the profile and has not put it back: the three above stay as found
static int creations, removals; // the profile's "created" and "removed" lines this run, numbered (under lock)
static char state_path[MAX_PATH + 32]; // rtss-profile.txt next to the executable; "" when its path is unknown
static RtssSaved rec; // this run's line in it, while the profile holds a value of this run
static _Atomic int recorded; // the file holds rec: a crash leaves the profile to the next start
static int state_off; // a line this run could not act on stays as it is: no writes of the file
static _Atomic uint32_t rtss_tid; // the worker's thread, which may hold the lock when a crash comes
// RTSS's shared memory as rtss_parse reads it: whether RTSS runs, whether it lists this process, and its frame count.
static void rtss_map(RtssApp *a) {
memset(a, 0, sizeof *a);
HANDLE m = OpenFileMappingA(FILE_MAP_READ, FALSE, "RTSSSharedMemoryV2");
if (!m) return;
const uint8_t *p = MapViewOfFile(m, FILE_MAP_READ, 0, 0, 0);
MEMORY_BASIC_INFORMATION mi;
if (p && VirtualQuery(p, &mi, sizeof mi)) rtss_parse(p, mi.RegionSize, GetCurrentProcessId(), a);
if (p) UnmapViewOfFile(p);
CloseHandle(m);
}
static int load_hooks(void) {
if (dll) return 1;
dll = LoadLibraryA(dll_path);
if (!dll) {
rt_log("frame rate: RivaTuner Statistics Server's %s did not load (%lu)", dll_path, GetLastError());
return 0;
}
enum_profiles = (EnumProfilesFn)(void *)GetProcAddress(dll, "EnumProfiles");
load_profile = (ProfileFn)(void *)GetProcAddress(dll, "LoadProfile");
save_profile = (ProfileFn)(void *)GetProcAddress(dll, "SaveProfile");
delete_profile = (ProfileFn)(void *)GetProcAddress(dll, "DeleteProfile");
get_property = (PropertyFn)(void *)GetProcAddress(dll, "GetProfileProperty");
set_property = (PropertyFn)(void *)GetProcAddress(dll, "SetProfileProperty");
update_profiles = (UpdateFn)(void *)GetProcAddress(dll, "UpdateProfiles");
if (enum_profiles && load_profile && save_profile && delete_profile && get_property && set_property && update_profiles)
return 1;
rt_log("frame rate: RivaTuner Statistics Server's hooks library lacks the profile calls");
FreeLibrary(dll);
dll = NULL;
return 0;
}
// The profile exists on its own: RTSS keeps it as Profiles\<name>.cfg in its folder. EnumProfiles would say the same
// (a comma-separated list of names) but crashes this process: it walks Profiles\*.cfg with _findfirst and _findnext and
// keeps the find handle in 32 bits. The handle points into the process heap, which here lies above 4 GB (the guest
// address space takes the low part, memory.c mem_init), so _findnext gets a cut pointer and faults in ntdll writing to
// it. NX2NAVI_RTSS_ENUM=1 calls EnumProfiles anyway, to test an RTSS that keeps the whole handle.
static int profile_exists(void) {
if (!rt_knob("RTSS_ENUM", "0")) {
char path[MAX_PATH * 2];
snprintf(path, sizeof path, "%s\\Profiles\\%s.cfg", rtss_dir, profile);
return GetFileAttributesA(path) != INVALID_FILE_ATTRIBUTES;
}
char list[8192];
DWORD need = enum_profiles(list, sizeof list);
list[sizeof list - 1] = 0;
if (need > sizeof list)
rt_log("frame rate: RivaTuner Statistics Server lists %lu bytes of profiles; reading the first %zu", need, sizeof list);
size_t n = strlen(profile);
for (char *s = list; *s;) {
char *end = strchr(s, ',');
size_t len = end ? (size_t)(end - s) : strlen(s);
if (len == n && !_strnicmp(s, profile, n)) return 1;
if (!end) break;
s = end + 1;
}
return 0;
}
// Writes limit/denominator into the profile and reads them back from the saved file: 1 when they stuck, 0 when RTSS
// refused a value, -1 when the save did not reach the file. RTSS checks each value against the other one already in
// the profile (the limit over the denominator must be a rate it accepts: 165080 is refused over 1 and taken over 1000),
// so the limit goes to 0 (none) before the denominator changes.
static int write_limit(DWORD limit, DWORD denominator) {
load_profile(profile);
DWORD none = 0;
if (!set_property("FramerateLimit", (LPBYTE)&none, sizeof none)) return 0;
if (has_denominator && !set_property("FramerateLimitDenominator", (LPBYTE)&denominator, sizeof denominator)) return 0;
if (limit && !set_property("FramerateLimit", (LPBYTE)&limit, sizeof limit)) return 0;
save_profile(profile);
load_profile(profile);
DWORD l = ~0u, d = 1;
if (!get_property("FramerateLimit", (LPBYTE)&l, sizeof l)) return -1;
if (has_denominator && !get_property("FramerateLimitDenominator", (LPBYTE)&d, sizeof d)) return -1;
return l == limit && (!has_denominator || d == denominator) ? 1 : -1;
}
// The state file with this profile's line set to r, or removed for r NULL; the file goes once it holds no line. Worker
// thread only, under lock. 1 when the file holds what was asked.
static int state_put(const RtssSaved *r) {
if (state_off || !*state_path) return 0;
uint64_t n = 0;
char *text = (char *)plat_file_read(state_path, &n);
size_t cap = (size_t)(text ? n : 0) + sizeof RTSS_SAVED_HEADER + 2 * (RTSS_NAME_MAX + 96);
char *out = malloc(cap);
int records = 0, ok = 0;
size_t len = out ? rtss_saved_update(text ? text : "", profile, r, out, cap, &records) : 0;
if (len && !records) {
plat_file_delete(state_path);
ok = !plat_file_exists(state_path);
} else if (len) {
ok = plat_file_write_atomic(state_path, out, len);
}
free(out);
free(text);
return ok;
}
// The state file did not take this run's line: a crash then puts the profile back as an orderly exit does
// (rtss_limit_crash). Logged once a run.
static void unrecorded(void) {
static int said;
atomic_store(&recorded, 0);
if (!state_off && !said++)
rt_log("frame rate: %s could not be written; a crash would leave the game's limit in RivaTuner Statistics "
"Server's profile %s", *state_path ? state_path : "rtss-profile.txt", profile);
}
// Before a write of limit/denominator into the profile: rec's written value is that one, and the one written before it
// stays as the previous one (rtss_decide.h RtssSaved), so that a run that ends before the profile changed, or after,
// leaves a profile the next start tells from a value the player set since.
static void record_before(DWORD limit, DWORD denominator) {
if (!atomic_load(&recorded) || (rec.limit == limit && rec.den == denominator)) return;
rec.prev_limit = rec.limit, rec.prev_den = rec.den;
rec.limit = limit, rec.den = denominator;
if (!state_put(&rec)) unrecorded();
}
// After that write: the profile holds the written value alone (landed), or kept the previous one.
static void record_after(int landed) {
if (!atomic_load(&recorded) || (rec.limit == rec.prev_limit && rec.den == rec.prev_den)) return;
if (landed) rec.prev_limit = rec.limit, rec.prev_den = rec.den;
else rec.limit = rec.prev_limit, rec.den = rec.prev_den;
if (!state_put(&rec)) unrecorded();
}
// Writes mhz into the profile (rtss_poll's RtssOps.apply): RTSS_WROTE_*. first: the run's first write, which records
// what to put back on exit, in the state file too before anything changes; clear: on a refusal, clear the limit of this
// run the profile holds.
static int apply(void *ctx, int64_t mhz, int first, int clear) {
(void)ctx;
if (first && !owed) { // what to put back on exit; after a put back that did not land, the profile still owes it
had_profile = profile_exists();
load_profile(profile);
old_limit = 0, old_denominator = 1;
get_property("FramerateLimit", (LPBYTE)&old_limit, sizeof old_limit);
has_denominator = get_property("FramerateLimitDenominator", (LPBYTE)&old_denominator, sizeof old_denominator);
if (!old_denominator) old_denominator = 1;
}
uint32_t l, d;
rtss_limit_value(mhz, has_denominator, &l, &d);
DWORD limit = l, denominator = d;
if (first) { // the line goes in before the profile changes: a kill between the two finds it as it was
RtssSaved was = rec; // still in the file after a put back that did not land: the profile holds its value
int kept = atomic_load(&recorded);
rec = (RtssSaved){had_profile, old_limit, old_denominator, limit, denominator, kept ? was.limit : limit,
kept ? was.den : denominator, ""};
snprintf(rec.name, sizeof rec.name, "%s", profile);
if (state_put(&rec)) atomic_store(&recorded, 1);
else unrecorded();
} else {
record_before(limit, denominator);
}
int r = write_limit(limit, denominator);
if (r < 0) { // the line keeps both values: the profile may hold either
rt_log("frame rate: RivaTuner Statistics Server's profile %s did not keep the limit: its Profiles folder needs the "
"game run as administrator", profile);
if (first && !owed && atomic_exchange(&recorded, 0)) state_put(NULL); // the profile is as it was
return RTSS_WROTE_READONLY;
}
owed = 1;
if (first) {
if (!had_profile) // numbered: a want that flips between a limit and kept out creates it again at each flip
rt_log("frame rate: RivaTuner Statistics Server profile %s created; it goes away when the game closes "
"(creation %d this run)", profile, ++creations);
else
rt_log("frame rate: RivaTuner Statistics Server profile %s held a limit of %lu/%lu; it gets that back when the "
"game closes", profile, old_limit, old_denominator);
}
if (r == 0) { // RTSS must not keep an earlier limit of this run under the game's own one
rt_log("frame rate: RivaTuner Statistics Server refused a limit of %.3f fps", mhz / 1000.0);
record_after(0);
if (!clear) return RTSS_WROTE_REFUSED;
record_before(0, 1);
int cleared = write_limit(0, 1) > 0;
record_after(cleared);
if (!cleared) return RTSS_WROTE_REFUSED;
update_profiles();
return RTSS_WROTE_REFUSED_CLEARED;
}
record_after(1);
update_profiles();
if (!mhz) rt_log("frame rate: RivaTuner Statistics Server's profile %s has no limit", profile);
else if ((int64_t)limit * 1000 == mhz * denominator)
rt_log("frame rate: RivaTuner Statistics Server's profile %s limits to %.3f fps", profile, mhz / 1000.0);
else // rounded to whole frames by an RTSS without FramerateLimitDenominator: the log says what RTSS holds
rt_log("frame rate: RivaTuner Statistics Server's profile %s limits to %lu/%lu (%.3f fps; the game asked %.3f)",
profile, limit, denominator, (double)limit / denominator, mhz / 1000.0);
return RTSS_WROTE_HELD;
}
// The profile as it was before this run changed it (the caller checked S.changed or owed, and marks S done). Its line
// in the state file goes once it is back; one that did not go back keeps the line, and owed, for the exit and the next
// start.
static void put_back(void) {
int back = 1;
if (!had_profile) {
delete_profile(profile);
back = !profile_exists();
if (back)
rt_log("frame rate: RivaTuner Statistics Server profile %s removed, as before the game started "
"(removal %d this run)", profile, ++removals);
else
rt_log("frame rate: RivaTuner Statistics Server profile %s did not go away", profile);
} else if (write_limit(old_limit, old_denominator) > 0) {
rt_log("frame rate: RivaTuner Statistics Server profile %s has its limit of %lu/%lu back", profile, old_limit,
old_denominator);
} else {
rt_log("frame rate: RivaTuner Statistics Server profile %s did not take its old limit back", profile);
back = 0;
}
update_profiles();
if (back) owed = 0;
if (back && atomic_exchange(&recorded, 0)) state_put(NULL);
}
// This profile's line in the state file into r: 1 when there is one.
static int left_line(RtssSaved *r) {
uint64_t n = 0;
char *text = *state_path ? (char *)plat_file_read(state_path, &n) : NULL;
int found = text && rtss_saved_find(text, profile, r);
free(text);
return found;
}
// The line r of this profile, left in the state file by a run that did not close (left_start): before this run changes
// anything, the profile gets back what it held before that run (rtss_restore_plan), unless the player changed it since,
// and the line goes. A profile that does not go back keeps the line, and this run leaves the file alone.
static void restore_left(const RtssSaved *r) {
int exists = profile_exists();
load_profile(profile);
DWORD limit = 0, den = 1;
get_property("FramerateLimit", (LPBYTE)&limit, sizeof limit);
has_denominator = get_property("FramerateLimitDenominator", (LPBYTE)&den, sizeof den);
if (!den) den = 1;
int plan = rtss_restore_plan(r, exists, limit, den);
if (plan == RTSS_PLAN_DELETE) delete_profile(profile);
if ((plan == RTSS_PLAN_WRITE && write_limit(r->old_limit, r->old_den) <= 0) ||
(plan == RTSS_PLAN_DELETE && profile_exists())) {
rt_log("frame rate: RivaTuner Statistics Server profile %s still has the game's limit of %lu/%lu from a run "
"that did not close, and did not go back as it was; %s keeps its line", profile, limit, den, state_path);
state_off = 1;
return;
}
if (plan == RTSS_PLAN_WRITE || plan == RTSS_PLAN_DELETE) update_profiles();
if (plan == RTSS_PLAN_WRITE)
rt_log("frame rate: RivaTuner Statistics Server profile %s had the game's limit of %lu/%lu from a run that did "
"not close (%s); its limit of %u/%u is back", profile, limit, den, state_path, r->old_limit, r->old_den);
else if (plan == RTSS_PLAN_DELETE)
rt_log("frame rate: RivaTuner Statistics Server profile %s had the game's limit of %lu/%lu from a run that did "
"not close (%s); removed, as before that run", profile, limit, den, state_path);
else if (plan == RTSS_PLAN_LEAVE)
rt_log("frame rate: RivaTuner Statistics Server profile %s changed since a run that did not close (%s); left as "
"it is", profile, state_path);
else
rt_log("frame rate: RivaTuner Statistics Server profile %s is as it was before a run that did not close (%s)",
profile, state_path);
if (!state_put(NULL)) {
rt_log("frame rate: %s could not be written; its line for %s stays", state_path, profile);
state_off = 1;
}
}
// The state file's line for this profile, on the worker's first poll that finds RTSS running (w the want it read,
// hooks as RtssIn.hooks): 1 once the line is dealt with or there is none, 0 when a restore is still owed, for the first
// poll with the hooks library. A limit puts the profile back with the library it loads for its own writes. Frame
// generation and low latency keep RTSS out of the run's limit, but a profile left with an old limit would hold the
// game beside them (two limiters, and under frame generation RTSS would count the generated frames too): the library is
// loaded for that one restore. NX2NAVI_RTSS=0 keeps the thread read-only, so the log says what the file holds.
static int left_start(int64_t w, int hooks) {
RtssSaved r;
if (!left_line(&r)) return 1;
if (hooks != 1 && w == RTSS_OUT_SETTING) {
rt_log("frame rate: %s holds the game's limit of %u/%u for RivaTuner Statistics Server's profile %s from a run "
"that did not close; NX2NAVI_RTSS=0 leaves the profile as it is, and a start with RTSS let in puts it "
"back", state_path, r.limit, r.den, profile);
return 1;
}
if (hooks != 1 && (w >= 0 || !load_hooks())) return 0;
restore_left(&r);
return 1;
}
static void put_back_op(void *ctx, const RtssOut *o) { // rtss_poll's RtssOps.put_back
(void)ctx;
if (o->why == RTSS_WHY_FAILED && S.fail == RTSS_FAIL_NOT_LIMITING) // the frame count check gave up on RTSS
rt_log("frame rate: RivaTuner Statistics Server measures %.1f fps with its limit at %.3f fps", o->fps,
o->mhz / 1000.0);
else if (o->why == RTSS_WHY_FAILED) // the lines before say which sign of its hold never came
rt_log("frame rate: RivaTuner Statistics Server listed the game with its limit at %.3f fps for %d s and never "
"showed it holds it; the game's own limiter for the rest of the run", o->mhz / 1000.0,
RTSS_UNPROVEN_POLLS * POLL_MS / 1000);
put_back();
}
// Whether RTSS holds the game to the want w this poll read, and the log of which limiter is in use: the first decision,
// then each change of the limiter or of why the game's own one paces (RTSS not hooked yet, not read the profile, a
// minimised window), up to LIMITER_LINES lines. The want may have changed since the poll read it: rtss_limit_active
// compares the two, so a stale store never lets the game's limiter stand aside for a newer want. The store is made
// either way (a want that went w, w2, w again must not find an older hold), but a hold is logged only while its want
// is still the current one: the game's limiter paces a newer want, and the next poll logs what holds it.
#define LIMITER_LINES 100
static void set_active(int64_t w, int on, const char *why) {
static int logged = -1, lines;
static char said[128];
atomic_store(&active_for, on ? w : RTSS_UNSET);
if ((on && atomic_load(&want) != w) || (logged == on && (on || !strcmp(why, said)))) return;
logged = on;
snprintf(said, sizeof said, "%s", on ? "" : why);
if (++lines > LIMITER_LINES) return;
if (on) rt_log("frame rate: RivaTuner Statistics Server does the limiting; the game's own limiter stands aside");
else rt_log("frame rate: the game's own limiter (%s)", why);
if (lines == LIMITER_LINES) rt_log("frame rate: later changes of the limiter in charge are not logged");
}
// RTSS's own settings for the game, read from its profile file as RTSS keeps it: Profiles\<exe>.cfg, else
// Profiles\Global, and a key the game's profile lacks from Global. The system's ini reader does it, so no call goes into
// RTSS's hooks library, which a poll of a kept-out want never loads. The files are read again only when the time
// either was written changes. Worker thread only, while RTSS lists the game (watch).
static RtssSettings settings;
static char settings_file[MAX_PATH + 16]; // which file they came from, as Profiles\<name>
static uint64_t file_stamp(const char *path) { // 0: no such file
WIN32_FILE_ATTRIBUTE_DATA a;
if (!GetFileAttributesExA(path, GetFileExInfoStandard, &a)) return 0;
return ((uint64_t)a.ftLastWriteTime.dwHighDateTime << 32 | a.ftLastWriteTime.dwLowDateTime) | 1;
}
static int cfg_int(const char *own, const char *global, const char *key) {
char v[32] = "";
if (own) GetPrivateProfileStringA("Framerate", key, "", v, sizeof v, own);
if (!*v) GetPrivateProfileStringA("Framerate", key, "", v, sizeof v, global);
return (int)strtol(v, NULL, 10); // GetPrivateProfileIntA would read a negative scanline as 0
}
static void read_settings(void) {
static uint64_t stamps[2] = {~0ull, ~0ull}; // never read
char own[MAX_PATH * 2], global[MAX_PATH * 2];
snprintf(own, sizeof own, "%s\\Profiles\\%s.cfg", rtss_dir, profile);
snprintf(global, sizeof global, "%s\\Profiles\\Global", rtss_dir);
uint64_t a = file_stamp(own), b = file_stamp(global);
if (a == stamps[0] && b == stamps[1]) return;
stamps[0] = a, stamps[1] = b;
const char *p = a ? own : NULL;
settings = (RtssSettings){cfg_int(p, global, "Limit"), cfg_int(p, global, "LimitDenominator"),
cfg_int(p, global, "LimitTime"), cfg_int(p, global, "LimitTimeDenominator"),
cfg_int(p, global, "SyncScanline0"), cfg_int(p, global, "SyncScanline1"),
cfg_int(p, global, "SyncDisplay"), cfg_int(p, global, "ReflexSleep"),
cfg_int(p, global, "PassiveWait")};
if (a) snprintf(settings_file, sizeof settings_file, "Profiles\\%s.cfg", profile);
else snprintf(settings_file, sizeof settings_file, "Profiles\\Global");
}
// While RTSS lists the game, the settings it holds for it are logged once per change: what RTSS applies may be more than
// the FramerateLimit the game writes (a frame time limit, scanline sync, its own Reflex). While RTSS has no part in the
// game's limit (rtss_out_of_limit: kept out, w < 0, or out for the run because its profile cannot be written or its
// hooks library did not load) the game's own limiter, the driver's low latency or frame generation holds the frames,
// and a limit the player set in RTSS itself for the game (its global profile or the game's own, a frame time limit or
// scanline sync) still holds them a second time. Logged once per change, and again when the other limiter changes;
// the menu shows it for a kept-out want (rtss_limit_state 4), while a profile that cannot be written keeps its own note
// there (state 2).
static void watch(int64_t w, int fail, int listed) {
static int said, lines, settings_lines;
static const char *said_by;
static char said_text[128], said_settings[MAX_PATH + 256];
int out = rtss_out_of_limit(w, fail), on = 0;
const char *by = rtss_out_limiter(w); // the limiter beside RTSS's own setting
char text[128] = "";
if (listed) {
read_settings();
char all[MAX_PATH + 256], modes[192];
rtss_settings_text(modes, sizeof modes, &settings, 1);
snprintf(all, sizeof all, "(%s): %s", settings_file, modes);
if (strcmp(all, said_settings) && settings_lines++ < 40)
rt_log("frame rate: RivaTuner Statistics Server's settings for the game %s", all);
snprintf(said_settings, sizeof said_settings, "%s", all);
}
if (out && listed) {
on = rtss_two(w, fail, listed, rtss_settings_limit(&settings));
rtss_settings_text(text, sizeof text, &settings, 0);
}
if (!out) said = 0; // RTSS takes the game's limit: a later want out of it logs afresh
else if (on != said || (on && (strcmp(text, said_text) || by != said_by))) {
if (lines++ < 40) {
if (on)
rt_log("frame rate: two limiters: RivaTuner Statistics Server's own setting for the game (%s: %s) limits "
"it beside %s", settings_file, text, by);
else
rt_log("frame rate: RivaTuner Statistics Server no longer limits the game beside %s", by);
}
said = on, said_by = by;
snprintf(said_text, sizeof said_text, "%s", text);
}
atomic_store(&two, on && w < 0); // the menu's note (rtss_limit_state 4) is for a kept-out want
}
// RTSS's folder (InstallDir under its registry key), its hooks library and this executable's profile name, on the
// worker before its first poll. 0 when RTSS is not installed: the game's own limiter for the run.
static int find_rtss(void) {
char dir[MAX_PATH];
DWORD size = sizeof dir;
if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\WOW6432Node\\Unwinder\\RTSS", "InstallDir", RRF_RT_REG_SZ, NULL, dir,
&size) != ERROR_SUCCESS) {
rt_log("frame rate: RivaTuner Statistics Server is not installed; the game's own limiter");
return 0;
}
snprintf(dll_path, sizeof dll_path, "%s\\RTSSHooks64.dll", dir);
snprintf(rtss_dir, sizeof rtss_dir, "%s", dir);
if (GetFileAttributesA(dll_path) == INVALID_FILE_ATTRIBUTES) {
rt_log("frame rate: RivaTuner Statistics Server's folder %s has no RTSSHooks64.dll; the game's own limiter", dir);
return 0;
}
char exe[MAX_PATH];
DWORD n = GetModuleFileNameA(NULL, exe, sizeof exe);
if (!n || n >= sizeof exe) return 0;
const char *base = exe;
for (const char *s = exe; *s; s++)
if (*s == '\\' || *s == '/') base = s + 1;
snprintf(profile, sizeof profile, "%s", base); // RTSS names a profile after the executable, as ocarina.exe
char path[MAX_PATH], *cut; // the state file next to the executable, where nx2navi.ini is (cfg.c find_ini)
if (plat_exe_path(path, sizeof path) && (cut = plat_last_sep(path)))
snprintf(state_path, sizeof state_path, "%.*s%s", (int)(cut + 1 - path), path, "rtss-profile.txt");
int64_t first = atomic_load(&want);
if (first >= 0)
rt_log("frame rate: RivaTuner Statistics Server found in %s; it takes the frame limit while it runs", dir);
else
rt_log("frame rate: RivaTuner Statistics Server found in %s; kept out (%s), and while it is kept out its "
"thread only reads RTSS's own settings for the game, to tell when RTSS limits it a second time", dir,
rtss_out_text(first));
return 1;
}
static void worker(void *arg) {
(void)arg;
atomic_store(&rtss_tid, plat_thread_id());
if (!find_rtss()) return;
atomic_store_explicit(&ready, 1, memory_order_release); // rtss_limit_restore may read the names now
int was_running = -1, was_listed = 0, left_done = 0, left_tried = 0;
const RtssOps ops = {NULL, apply, put_back_op};
for (;; plat_sleep_ms(POLL_MS)) {
int64_t w = atomic_load(&want);
if (w == RTSS_UNSET) continue;
plat_mutex_lock(&lock);
if (atomic_load(&closed)) { // a want read before rtss_limit_restore must not reach the profile after it
plat_mutex_unlock(&lock);
continue;
}
RtssIn in = {.want = w, .movie_mhz = atomic_load(&movie_mhz), .now_ns = kern_now_ns(),
.hosted_ns = gfx_window_hosted_ns()};
rtss_map(&in.app);
if (in.app.running != was_running || in.app.listed != was_listed) {
if (!in.app.running) rt_log("frame rate: RivaTuner Statistics Server is installed but not running");
else if (!in.app.listed) rt_log("frame rate: RivaTuner Statistics Server runs; it has not hooked the game yet");
else rt_log("frame rate: RivaTuner Statistics Server has hooked the game");
was_running = in.app.running, was_listed = in.app.listed;
}
in.hooks = dll ? 1 : rtss_wants_hooks(&S, &in) ? load_hooks() : -1;
if (!left_done && in.app.running && (in.hooks == 1 || !left_tried)) { // before this run writes anything
left_tried = 1;
left_done = left_start(w, in.hooks);
}
in.current = atomic_load(&want) == w;
RtssOut o = rtss_poll(&S, &in, &ops);
atomic_store(&failed, S.fail);
if (o.why != RTSS_WHY_NEWER) { // a newer want waits for the next poll
char why[128];
rtss_why_text(why, sizeof why, &o, S.fail);
set_active(w, o.holds, why);
}
watch(w, S.fail, in.app.listed);
plat_mutex_unlock(&lock);
}
}
void rtss_limit_want(int64_t mhz) {
if (atomic_load_explicit(&closed, memory_order_relaxed)) return; // the exit put the profile back
// a new limit: the game's own limiter holds it until the worker had RTSS apply it, up to POLL_MS later (a movie's
// cap that waited for that ran the movie's 60 Hz count fast for half a second): active_for no longer matches
atomic_store(&want, mhz);
// The first want starts the worker, a kept-out one too (the worker then only reads, watch). The worker looks for
// RTSS itself (find_rtss), so the present that gets here waits on no registry key or file.
if (!atomic_load_explicit(&started, memory_order_relaxed) && !atomic_exchange(&started, 1) &&
!plat_thread_spawn(worker, NULL, "rtss", 0))
rt_log("frame rate: the RivaTuner Statistics Server thread did not start");
}
void rtss_limit_movie(int hz) { atomic_store_explicit(&movie_mhz, (int64_t)hz * 1000, memory_order_relaxed); }
int rtss_limit_active(void) {
if (atomic_load_explicit(&closed, memory_order_relaxed)) return 0;
int64_t a = atomic_load_explicit(&active_for, memory_order_relaxed);
return a >= 0 && a == atomic_load_explicit(&want, memory_order_relaxed);
}
int rtss_limit_state(void) {
if (atomic_load_explicit(&closed, memory_order_relaxed)) return 0;
int f = atomic_load_explicit(&failed, memory_order_relaxed);
int64_t w = atomic_load(&want);
if (w >= 0 && (f == RTSS_FAIL_READONLY || f == RTSS_FAIL_NOT_LIMITING || f == RTSS_FAIL_UNPROVEN))
return f == RTSS_FAIL_READONLY ? 2 : 3;
if (w < 0 && w != RTSS_UNSET && atomic_load_explicit(&two, memory_order_relaxed)) return 4;
return rtss_limit_active() && w > 0;
}
void rtss_limit_restore(void) {
// The game presents on after this call (the rest of the exit runs first), so from here the game's own limiter holds
// the frames left and no want reaches the profile again: the worker would otherwise write the game's limit back into
// the profile just put back, and the next run would take it for the player's own. Set before the check below, so
// that an exit before the first limit, or while RTSS was kept out, also stops a later want from starting the worker.
atomic_store(&closed, 1);
if (!atomic_load_explicit(&ready, memory_order_acquire)) return;
// the exit may come from a crash on any thread: give up after a second rather than wait on a lock held there
for (int i = 0; i < 100; i++) {
if (plat_mutex_trylock(&lock)) {
if (dll && (S.changed || owed)) put_back(), rtss_put_back_done(&S);
plat_mutex_unlock(&lock);
return;
}
plat_sleep_ms(10);
}
}
// A crash's and a fatal error's end (main.c crash_handler, dispatch.c fatal_end), from any thread: no want reaches the
// profile any more, and no call goes into RTSS's library, which may be what failed and is no safe call on a damaged
// heap. The state file keeps this run's line, and the next start puts the profile back from it (restore_left). Without
// that line (the file could not be written) the profile goes back as at an orderly exit, but not from the worker's own
// thread, which may hold the lock that takes.
void rtss_limit_crash(void) {
atomic_store(&closed, 1);
atomic_store(&active_for, RTSS_UNSET);
if (atomic_load(&recorded) || plat_thread_id() == atomic_load(&rtss_tid)) return;
rtss_limit_restore();
}
#else
void rtss_limit_want(int64_t mhz) { (void)mhz; }
void rtss_limit_movie(int hz) { (void)hz; }
int rtss_limit_active(void) { return 0; }
int rtss_limit_state(void) { return 0; }
void rtss_limit_restore(void) {}
void rtss_limit_crash(void) {}
#endif