21 Commits

Author SHA1 Message Date
chrg6
c19f75e505 Check that a crash on RivaTuner Statistics Server's own thread, with no line in the state file, returns at once.
The review found that no test ran rtss_limit_crash's thread gate: where the state file holds no line of the run,
rtss_limit_crash puts the profile back through rtss_limit_restore, except on the worker's own thread, which may hold
the lock that restore tries for a second; only the static check in tests/rtss_limit_check.py looked at it.
tests/rtss_worker_check.c crash_worker_thread now makes the state file unwritable and calls rtss_limit_crash from inside
SaveProfile on the worker thread, with the lock held: it must return within 200 ms with no call into the fake hooks
library (a new count of every call), close the run, and let no later want reach the profile. With the gate removed
from a copy of rtss_limit.c, the scenario fails.

Checks that passed: tests/rtss_limit_check.py, tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
9f45d7448f Leave rtss-profile.txt alone when it is there but does not read, so the lines of other executables in the folder stay.
The review found that rtss_limit.c state_put took a state file plat_file_read could not read for a missing one: a file
another program held open (a scan) was rewritten with this profile's line alone, or deleted when the run removed its
line, and the lines of other executables that share the folder (dev or probe builds) were lost. state_put now returns
0 when the read gave nothing but plat_file_size finds the file with bytes in it; the run then goes on as with a file
that cannot be written (one log line, and a crash puts the profile back as an orderly exit does). An empty file is
still rewritten. tests/rtss_worker_check.c state_locked checks a file holding another executable's line that does not
read: the run limits as before, logs it, and the file stays as it was through the exit.

Not reproduced before the change. Checks that passed: tests/rtss_limit_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
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
chrg6
1744681f1a Give up on RivaTuner Statistics Server for the run when it hooks the game with the limit for 30 s and never shows it holds it.
The review found that nothing ended an RTSS hold that was never proven: when RTSS listed the game with the limit in
its profile but one sign never came (presents of another API value than Vulkan's, the update flag never cleared, a
period that never moved), the profile kept the limit and the game's pacer held the frames too for the whole run, two
limiters where the pacing rules allow one, with only a reason line in the log and nothing in the menu. Before the
hold had to be proven, RTSS alone held the game once it listed it. rtss_decide.h rtss_decide now counts the polls in a
row where RTSS lists the game, the profile holds a limit of this run (not "none"), a picture went out within 200 ms
and the hold is not shown; at RTSS_UNPROVEN_POLLS (60, 30 s) it puts the profile back and RTSS is out for the run
(RTSS_FAIL_UNPROVEN, "RivaTuner Statistics Server never showed it holds the game"). A minimised window, RTSS not
hooked, a want of no limit, a kept-out want or a poll that shows the hold start the count again. rtss_limit.c logs the
give-up, rtss_limit_state returns 3 for it, so the menu says RivaTuner does not limit the game and the game does, and
rtss_out_of_limit counts it, so a limit set in RTSS itself is reported as a second limiter. The comment above
rtss_decide, rewritten for the rule, is wrapped at 120 columns and no longer cites rtss_fresh, which does not exist.
tests/rtss_limit_check.c checks each sign that never comes (given up at the 60th poll, once, for the run), the polls
that do not count and a shown hold restarting the count; tests/rtss_worker_check.c unproven_run checks it against the
real worker. docs/architecture.md, docs/building.md and nx2navi.ini.example describe the rule and the remaining window
of two limiters before it.

This changes when the game takes the limit back from RTSS, beyond the approved rules: an RTSS whose hold never shows
in its shared memory (a version or setting that differs) is now left out after 30 s and the game's limiter holds
alone, as with rtss = 0, where it held the frames beside the game's limiter before. Not reproduced before the change.
Checks that passed: tests/rtss_limit_check.py (41 of 41), 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
chrg6
a7b4f0c7cb Put back a profile a run that did not close left in RivaTuner Statistics Server, also at a start that keeps RTSS out.
The review found that the worker read rtss-profile.txt only on its first poll with the hooks library, which it loads
only for a limit, so a start with frame generation on, with the driver's low latency on from the first present, or
with NX2NAVI_RTSS=0 never put back a killed run's limit: RTSS then went on limiting the game at that value (counting
the generated frames too under frame generation), and the log and the menu reported it as the player's own limit
beside the game's limiter. rtss_limit.c worker now looks at the state file on its first poll that finds RTSS running
(left_start): a limit puts the profile back with the library it loads anyway, as before; frame generation and low
latency load the library for that one restore (restore_left, which now takes the line found); NX2NAVI_RTSS=0 keeps the
thread read-only and logs that the file holds the game's limit from a run that did not close and that a start with
RTSS let in puts it back. A restore the library could not be loaded for waits for the first poll with the library.
tests/rtss_worker_check.c adds kill_restart_framegen (the next start asks frame generation first: the profile is
removed, no limit written, the line gone) and kill_restart_setting (NX2NAVI_RTSS=0: no library loaded, the profile and
the line stay, the log says so). The rtss_limit.c header, docs/architecture.md, docs/building.md and
nx2navi.ini.example say so, and the ini text no longer says the game alone paces movies under RTSS.

Not reproduced before the change. Checks that passed: tests/rtss_limit_check.py (40 of 40),
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
chrg6
60694532a1 Round a fractional limit down for a RivaTuner Statistics Server without the denominator field, so "display" never holds the game above the refresh.
apply() rounded the millihertz to the nearest whole frame where RTSS has no FramerateLimitDenominator, so "display" on
a 59.94 Hz monitor wrote 60 and on a 143.856 Hz one 144: above the refresh, one frame more than the monitor shows
every 17 s and every 7 s. Only "display" asks a fractional rate under RTSS (fixed limits and movie rates are whole,
frame generation keeps RTSS out), so the new rtss_decide.h rtss_limit_value rounds every fraction down when the
denominator is missing (59/1, 143/1), and keeps a whole rate over 1 and a fraction over 1000 where RTSS knows the
denominator. VRR and present-wait handling stay to be measured.

Not reproduced before the change. rtss_limit_check (T15: rtss_limit_value at 59940, 60000, 143856, 0 and 240000 with
and without the denominator) and rtss_worker_check (round_logged_whole now 59/1 and 143/1, round_logged) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
4d4b97b043 Let a want reach RivaTuner Statistics Server's profile only once it stayed two polls, and write nothing before the first host present.
A want that flips between a limit and kept-out created and removed RTSS's profile at each flip: with a driver's low
latency vk_lowlat_on() is 0 before the first swapchain and during every swapchain rebuild, so a warm start without the
boot screen wrote the profile and removed it a poll later, and a poll that fell into a rebuild did the same (lane A's
probe: 6 creations and 5 removals for a want flipping each poll, 1 and 1 at start before RTSS hooked the game).
rtss_decide now counts the polls in a row a want stayed: a limit goes into the profile and a kept-out want puts it
back only after RTSS_SETTLE_POLLS (2), while standing aside still ends at once (rtss_limit_active compares the want),
and nothing is written before the first host present (RTSS_WHY_NO_PRESENT). A limit change reaches the profile up to a
second later; the game's limiter holds the new limit meanwhile. The created and removed lines were numbered before.

Not reproduced before the change. rtss_limit_check (T8: kept out for one poll and for two, a second reason, a limit
again; T9: 20 flips after a written limit give no write or put back, 20 from the start none, flips every two polls
act; no write while no host present) and rtss_worker_check (churn_run and every scenario that now settles) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
f7899e5443 Let the game's limiter stand aside only once RivaTuner Statistics Server is seen holding the game, and give up on RTSS at 1.05 times the limit over three fresh periods.
The worker stood aside as soon as RTSS listed the process and the profile read back, before RTSS had seen a Vulkan
present (with InjectionDelay=15000 it lists the game some seconds after start) or read the new profile. Its frame
count check took the newest period whatever its age: a period frozen at 144 fps after the limit went to 60 and the
window was minimised failed RTSS for the run, and 1.15 times the limit was never caught (lane A's probe: listed_no_api,
stale_fps, over115). rtss_decide now parses the entry's flags and stands aside only when the profile held the target
at the poll before, RTSS lists the game with the Vulkan API value (flags & 0xFFFF == 0xA), RTSS_UPDATE_ASKED is clear,
a new period was seen within max(2.5 s, 2.5 times its length), no movie plays and a host present went out within
200 ms. Only a new period after a poll that stood aside is checked: three in a row over 1.05 times the limit put the
profile back for the run, and a stale period counts neither way. The worker reads the poll time and the newest host
present (gfx_window_hosted_ns), and logs each change of why the game's limiter paces, up to 100 lines.

Not reproduced before the change. rtss_limit_check (the flags at +264; T2 injection delay through not listed, no API,
the update flag, no period and holding; T3 quit; T6 the frame count at 144, 69, 63 fps, a frozen period and a newer
want; the idle case) and rtss_worker_check (over_run, over_slack_run, new api_run and idle_run, and every scenario that
now holds from the poll after the write) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:25 -05:00
chrg6
04f4d44747 Keep RivaTuner Statistics Server's profile as it was in rtss-profile.txt, so a start after a kill or crash puts it back, and leave it to that start at a crash.
An exit that skipped rtss_limit_restore (Task Manager, a hang, lost_wait_exit, a kill during the pipeline save) left
the game's limit in Profiles\<exe>.cfg, and the next run's first apply took it for the player's own: that run then
put the stale limit back at its close, and a profile the game had made stayed for good (lane A's probe: kill1, then
kill2 'held a limit of 60/1'). The crash handler and the fatal error's end also called into RTSSHooks64.dll from a
process that had failed (P-13).

rtss_limit.c now keeps one line per profile in rtss-profile.txt next to the executable (rtss_decide.h RtssSaved:
whether the profile existed, its old limit and denominator, the value the run wrote), written atomically before the
run's first change of the profile, updated with each value written, and removed once the profile is back (put_back;
also when the first write did not stick). On the first poll with the hooks library a start that finds a line of its
profile puts the profile back first (restore_left, rtss_restore_plan): the old limit, or no profile, unless the player
changed or removed it since, which the written value tells. main.c crash_handler and dispatch.c fatal_end call the
new rtss_limit_crash, which closes the run and leaves the profile to that start with no call into RTSS's library; only
when the file could not be written does it put the profile back as before, and never from the worker's own thread. A
file that cannot be written leaves the run as it was, with one line in the log. Profiles frozen by earlier versions
cannot be told from the player's own.

Not reproduced before the change. rtss_limit_check (T12: the line's format and parse, the file's rewrite, each plan;
the static check that both crash paths go through rtss_limit_crash), rtss_worker_check (state_record, state_player,
state_unwritable, crash_recorded, and kill_restart with its player and edited variants, each a killed run and the next
start in processes of their own), fatal_exit_check and exit_order_check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:25 -05:00
chrg6
208a10718d Let the game's pacer hold a movie while RivaTuner Statistics Server holds the limit, and keep the play limit in RTSS's profile through it.
During a movie frame_step_cap_ns wanted the movie's rate and returned 0 once RTSS held it, so pace() kept none of the
movie's slots while ocarina_step still gave one movie frame per update: each late frame put the picture behind the
sound for good, the fall-behind d3e50328 fixed for the game's pacer. Each movie start and end also rewrote RTSS's
profile (two saves and two UpdateProfiles per movie in lane A's probe). Now frame_step_cap_ns wants the play limit
through the movie and tells RTSS of the movie (rtss_limit_movie); rtss_pace_period keeps the movie's period from its
first frame, and the worker leaves the movie to the pacer (RTSS_WHY_MOVIE) with the profile as it is. Under a limit
below the movie's rate (30, 40, 45) the profile has no limit for the movie's length (rtss_decide.h rtss_target), since
RTSS at the movie's rate would space out the frames the pacer sends at once to catch up; that is still two writes per
movie there, and none at 60 or more.

Not reproduced before the change. rtss_limit_check (the movie case at 144 and 40, rtss_target, rtss_pace_period),
rtss_worker_check (movie_run, new movie_low_run) and frame_step_check (the want and period through a movie under RTSS)
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:25 -05:00
chrg6
c9c61e726f Wrap the lines the review's fixes left long in the RTSS docs, code and checks.
Rereading the fixes for the review showed lines past the 120 columns the files keep to: the RTSS paragraph of
docs/architecture.md ran one line to 176 columns after its hooks library sentence was replaced, rtss_limit.c had three
lines just over (the creations and removals counters, the removed line, find_rtss's kept-out line), and
tests/rtss_worker_check.c three. They are wrapped; no text, log line or check changes.

rtss_limit_check passes 27 of 27, check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
55e03cf792 Log RivaTuner Statistics Server's hold only while its want is still the current one.
The review found that A-11's fix stores the hold after every decision but also logs it then: when the want changed
while the worker wrote the profile (frame generation switched on during the first write), rtss_limit.c set_active
logged "RivaTuner Statistics Server does the limiting; the game's own limiter stands aside" while the game's limiter
was pacing the new want (rtss_limit_active compares the hold with the want), and the kept-out line followed one poll
later. set_active still stores the hold every time, so that a want going w, w2 and w again never finds an older hold,
but logs a hold only while the want is still the one decided for; a decision that RTSS does not hold the game is
logged as before. The next poll logs what holds the newer want.

tests/rtss_worker_check.c active_logged switches frame generation on inside the first SaveProfile (a hook in the fake
RTSS): no line may say RTSS does the limiting, the next poll logs the kept-out line, and a later limit is held and
logged. It fails on the code before this change. Log only; rtss_limit_check passes 27 of 27, active_race included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
4c919882fa Tell of two limiters also where RivaTuner Statistics Server is out of the limit because its profile is read-only or its library did not load.
The review found that docs/architecture.md's list of exceptions to the one-limiter rule leaves one out: when RTSS is
out of it for the run because its profile cannot be written (no administrator rights) or its hooks library does not
load, a limit the player set in RTSS itself (Global or the game's .cfg, a frame time limit, scanline sync) still holds
the game while the game's own limiter paces it. rtss_limit.c watch worked the second limiter out only for a kept-out
want, so neither the "two limiters" line nor anything else said so; only the "settings for the game" line showed the
values.

rtss_decide.h now has rtss_out_of_limit (kept out, or failed with RTSS_FAIL_READONLY or RTSS_FAIL_LIBRARY; a run the
frame count check gave up on is left out, since RTSS was seen not to hold the game), and rtss_two takes the failure.
watch gets S.fail and logs the two-limiter line for those runs too, and logs it again when the other limiter changes
(a failed run that then keeps RTSS out for low latency). The menu is unchanged: its two-limiter note (rtss_limit_state
4) stays for a kept-out want, and a read-only profile keeps its administrator note (state 2). Log only: no pacing
decision changes. The docs' exceptions sentence names the case and what the log and the menu show.

tests/rtss_worker_check.c readonly_two and library_two run it with a limit in Global; both fail on the code before
this change (no two-limiter line). rtss_limit_check.c's two() covers the failures. rtss_limit_check passes 26 of 26,
check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
9c32d3bca9 Number RivaTuner Statistics Server's profile created and removed lines, so a play-test can count the churn.
The review found that numbering these lines had been set aside as the performance branch's work, while it is the part
of A-10 that may land on its own, and only this branch writes rtss_limit.c. A want that flips between a limit and kept
out (low latency before the first swapchain and during a rebuild, or switched in the menu) creates the profile and
removes it again at each flip; the play-test's live toggles item counts those lines. rtss_limit.c apply now ends the
created line with "(creation N this run)" and put_back the removed line with "(removal N this run)", counted under the
lock both run under. The settle rule that would stop the churn still waits for approval.

tests/rtss_worker_check.c churn_run also checks that the ten flips log creations 1 to 6 and removals 1 to 5 and no
more. rtss_limit_check passes 24 of 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
8e8d6a2f51 Move RivaTuner Statistics Server's decisions into a function of their own, and pin them with a check that needs no RTSS.
The worker read RTSS's shared memory, decided and wrote the profile in one loop over file statics, so its decisions
could be checked only against a running RTSS. rtss_decide.h now holds the parts that make no system call: rtss_parse
reads the shared memory's header and this process's entry (bounded by the memory's size and 4096 entries, as before),
rtss_decide takes one poll's view (the want, whether it is still current, a closed run, the hooks library, the entry)
and the state between polls (RtssState: the limit applied and the one refused, whether the run changed the profile,
the frame count, the failure) and returns what to do and whether RTSS holds the game, and rtss_poll carries that out
through the profile calls rtss_limit.c hands it (apply and put_back, which keep their I/O and log lines). The worker,
rtss_limit_restore and the menu state read the same state.

No decision changes: the order of the rules, the immediate write before RTSS lists the game, six polls over 1.2 times
the limit, a write per movie and per switch of low latency all stay as they are. The rules that wait for approval are
not in it (the pacer keeping movies and windows with no host present, Vulkan flags, settling and a fresh period
before standing aside, rounding the limit down, the state file).

tests/rtss_worker_check.c gains nine scenarios that pin those decisions against the real worker (a read-only profile,
a refused limit, the frame count rule at 144, 69, 73 fps and unlimited, RTSS quitting, a hooks library that does not
load, low latency switching, a movie); the logs of all 23 scenarios are the same, line for line, before and after this
change. tests/rtss_limit_check.c checks the same decisions on rtss_decide.h alone, with a fake RTSSSharedMemoryV2 for
the reader (a bad header, short entries, an entry past the memory or past the 4096th) and a fake profile behind
RtssOps; mutating the frame count's polls or slack, the refusal, the library failure or the clear of a refused limit
makes it fail. docs/building.md describes the check. Every runtime file compiles; -Wall adds no warning;
frame_step_check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
ecbd49441b Log RivaTuner Statistics Server's own settings for the game while it lists the game.
rtss_limit.c writes and reads back only FramerateLimit and FramerateLimitDenominator, but RTSS's [Framerate] section
holds more that limits or paces the frames: a frame time limit (LimitTime over LimitTimeDenominator), scanline sync
(SyncScanline0/1), display sync, its own Reflex sleep and passive waiting. A player with any of them set in Global gets
an RTSS that holds the game to more than the game's limit, and no log said so.

While RTSS lists the game, the worker's reader (read_settings, the system's ini reader on Profiles\<exe>.cfg, else
Profiles\Global, read again only when either file's write time changes) now also takes SyncDisplay, ReflexSleep and
PassiveWait, and watch logs "RivaTuner Statistics Server's settings for the game (<file>): limit L/D, frame time limit
T/TD, scanline sync S0/S1, display sync X, Reflex sleep R, passive wait P" once per change, 40 lines at most. It reads
only, makes no call into RTSS's hooks library and changes nothing RTSS or the game does; whether the game should keep
RTSS out when one of these is set is left for approval.

Not reproduced before the change: it needs RTSS's settings in a play-test. tests/rtss_limit_check.c checks the text
with and without the modes and that a short buffer cuts it; tests/rtss_worker_check.c gains settings_logged (nothing
read while the game is not listed, then one line for the game's profile with what it took from Global, and one more
after the limit changes). Both pass, with a compile of every runtime file; -Wall adds no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
6be25cc978 Look for RivaTuner Statistics Server on its own thread, so the first present no longer reads the registry and files.
rtss_limit_want ran start() on the thread that called it: the registry read of RTSS's folder, a file attribute query
under Program Files, the executable's name and the thread's creation. frame_step_cap_ns calls it from
nvnQueuePresentTexture, so a guest present paid for them once per run (or at the first present after frame
generation or low latency was switched off).

rtss_limit_want now only stores the want and, once, spawns the worker; the worker does the lookup and the "found" or
"not installed" line before its first poll (find_rtss) and ends when RTSS is not installed. rtss_limit_restore,
which may run on another thread, reads the names only once the worker has published them (ready). Pacing does not
change: the worker's first poll comes the time of the lookup later.

Not reproduced before the change (its cost was never measured). tests/rtss_worker_check.c gains start_off_present,
which counts the registry and file calls made on the thread that asked for the limit (3 before the change, 0 after),
and not_installed (the worker ends, the game's limiter paces, the exit has nothing to put back). The other scenarios
and a compile of rtss_limit.c with -Wall pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
b2b37a3e5d Log the limit RivaTuner Statistics Server's profile holds, not the one the game asked for, when RTSS rounded it.
An RTSS without FramerateLimitDenominator gets a fractional limit rounded to whole frames (apply: 59.94 becomes 60),
but the line after the write named the rate the game asked for: "limits to 59.940 fps" while the profile held 60. A
play-test log would hide that RTSS limits above a 59.94 Hz monitor. When the written limit over its denominator
differs from the asked rate, the line now names both: "limits to 60/1 (60.000 fps; the game asked 59.940)". What is
written does not change.

tests/rtss_worker_check.c gains round_logged (an RTSS with the denominator: 59940/1000, logged as 59.940 fps) and
round_logged_whole (without it: 59.94 and 143.856 Hz logged as 60/1 and 144/1, a whole 60 as before). The second
failed before the change and passes after it; a compile of rtss_limit.c with -Wall passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
07aca0170c Tie RivaTuner Statistics Server's hold to the want it was decided for, so a stale store no longer keeps the pacer aside.
The worker checked that the want was still the one it had read and then stored active = 1, two steps on two atomics.
A rtss_limit_want between them (frame generation or low latency switched on, a movie, a new limit) stored the new want
and active = 0, and the worker's store then set active back to 1: frame_step_cap_ns returned 0 for the kept-out want
until the next poll, up to 500 ms in which nothing limited the game.

The worker now stores the want RTSS holds the game to (active_for, or RTSS_UNSET for none), and rtss_limit_active is
true only while it equals the newest want, so a change of the want stops matching at once and rtss_limit_want no
longer clears anything. The frame count check against the limit keeps its test that the want has not changed.

tests/rtss_worker_check.c gains active_race: the worker polls without pause while the want switches 400000 times
between 60 fps and frame generation, and after each switch to frame generation rtss_limit_active must read 0 until
the worker has seen it. Before the change 128440 switches read 1; after it none do. The other scenarios and a compile
of rtss_limit.c with -Wall pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
b0488796a1 Tell in the log and the menu when RivaTuner Statistics Server's own limit holds the game beside another limiter.
With NX2NAVI_RTSS=0, frame generation or the driver's low latency, the game keeps RTSS out of its frame limit, but a
limit the player set in RTSS itself (its Global profile or the game's own: a frame rate limit, a frame time limit or
scanline sync) still holds the frames, on top of the game's limiter, the driver's or frame generation's. Nothing read
RTSS's settings in such a run: a first want that kept RTSS out never started its thread, and frame_step_cap_ns folded
the reasons into -1, so the log said "NX2NAVI_RTSS=0 or frame generation" for low latency too.

frame_step_cap_ns now passes the reason (rtss_decide.h RTSS_OUT_SETTING, RTSS_OUT_FRAMEGEN, RTSS_OUT_LOWLAT; the
unlimited branch too), and the kept-out line names it. A kept-out first want starts the worker as well. While RTSS is
kept out the worker never loads RTSS's hooks library and only reads: its shared memory, and once RTSS lists the game
the [Framerate] values of Profiles\<exe>.cfg, else Profiles\Global, with the system's ini reader, again only when
either file's write time changes. When one of them limits the game, the log names the file, the values and the other
limiter, once per change, and rtss_limit_state returns 4, which the frame rate row shows as a new note
(UI_MENU_FPS_RTSS_TWO, in English, Russian, Brazilian Portuguese and Simplified Chinese). A later want of a limit
(frame generation or low latency switched off) loads the hooks and hands RTSS the limit as before; NX2NAVI_RTSS=0
stays read-only for the run. Pacing does not change.

Not reproduced before the change: a stacked limit needs RTSS running in a play-test. tests/rtss_worker_check.c gains
three scenarios against the fake RTSS (a Global limit with NX2NAVI_RTSS=0, scanline sync in the game's profile under
low latency and then a limit, frame generation with no limit of RTSS's own), and tests/rtss_limit_check.c builds
rtss_decide.h alone and checks which settings hold the frames and their text. Both pass, with frame_step_check and a
compile of every runtime file; -Wall adds no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
a1153aa079 Keep RivaTuner Statistics Server's profile as the exit put it back, so the game's limit no longer returns to it.
rtss_limit_restore put the profile back and set the want to unset, but the game presents on until the process ends
(the rest of the exit runs after it). The next frame_step_cap_ns called rtss_limit_want with the limit, which replaced
the unset want, and the worker's next poll applied it again: the profile kept the game's limit after the exit, and the
next run took it for the player's own. An exit before the first want, or while RTSS was kept out, found the worker not
started, and a want after it started the worker, whose first poll applied at once. A want the worker read before the
restore was applied after it too, since the worker reads the want before it takes its lock.

rtss_limit_restore now marks the run closed before anything else: rtss_limit_want returns at once, the worker skips
its polls once it holds the lock, and rtss_limit_active and rtss_limit_state read 0, so the game's own limiter paces
the frames left.

tests/rtss_limit_check.py builds tests/rtss_worker_check.c, which compiles the real rtss_limit.c against a fake RTSS
(its registry key, hooks library, profiles and shared memory) and runs each scenario in a process of its own: a want
after the restore, the player's own limit, a want read before the restore, and an exit before the first want or while
RTSS was kept out. All five failed before the change and pass after it.

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