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>
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>
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>
frame_step_cap_ns returned 0 once RTSS held the limit, so pace() set each slot to now and the acquire never waited.
RTSS holds frames inside the host present, and a minimised window (gfx_present.c present_image skips the present) or
a frame hold gives it none to hold: the game then ran with no limiter but pace_smooth's floor, which follows the
intervals down, and the quick menu is where 640-700 fps read address 0 (frame_rate.c LIMIT_FREE_MAX_HZ).
gfx_present.c flip_locked now stores the time of each host present that took its picture (gfx_window_hosted_ns), and
rtss_decide.h rtss_pace_period gives the pacer the limit's period before the first host present and once none went
out for 200 ms (as vk_lowlat.c HOLD_IDLE_NS), and the 240 ceiling's period, never 0, while RTSS holds. The frames RTSS
lets through come further apart than the ceiling, so pace() waits for none of them; no new wait site.
Not reproduced before the change. frame_step_check (new cases: holding with a host present 10 and 199 ms ago, 250 ms
ago, none yet, not holding, 144 minimised, unlimited) and rtss_limit_check (rtss_pace_period) pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/cheats.py decodes the pc-relative instructions of a code cheat at main's base and held that base as its own constant, 0x7000000000. It equals recomp/layout.py FIRST_BASE today, where main is the first module of ORDER, but a change of the layout would have left the cheats decoding at the old base, and AGENTS.md has recomp/layout.py alone decide module bases. BASE is now layout.FIRST_BASE. cheats.py is part of the mods' lift key (make_game.py mod_digest), so an install with code cheats lifts once more at its next build; an install without them is not affected.
Not reproduced before the change: no fault today, the two values being equal. Checks that passed: tests/launcher_test.py (218 tests, a new one in which cheats.BASE follows a changed layout.FIRST_BASE, which fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
make_game.py modules_c writes gen/modules.c with the sha256 of the player's ExeFS modules and the install's absolute paths, and wrote it with no comment at all, so check_content.py, which refuses a file by its marker, its suffix, its name or a key line, would have let it into a commit. AGENTS.md wants the marker on every file the generator emits. Its first line now carries the marker and says what wrote it. It costs players one compile of modules.c and a link at their next build: make_game.py is part of the compile key, and every other object comes from the object cache.
The other half of the finding, the marker on the lift's layout.h, waits: it is a change to recomp/layout.py and recomp/gen.py, whose digest keys every player's lift (a full lift at the update) and the execution profile's fit, so it belongs to the next release that changes recomp/ anyway.
Not reproduced before the change: found by reading the code; the audit's probe showed modules_c without the marker. Checks that passed: tests/launcher_test.py (217 tests; the modules.c test now asserts the marker on its first line and that check_content.problems_for refuses the file, and fails before the change), tests/host_probes_test.py, tests/pgo_build_test.py and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kernel.c's preemption stub has two bodies since 00f06e66: kern_ap_stub_ymm saves YMM0-15 with vmovdqa and is chosen only when ap_init's cpu_keeps_ymm() finds AVX, OSXSAVE and XCR0 bits 1 and 2, and kern_ap_stub_xmm serves every other processor. tools/check_isa.py's DISPATCHED listed only sha256.c's blocks_ni, so it took the YMM body for an undispatched AVX function and stopped every tools/build_game.py --cpu v2 build. The entry names that check. Players' builds (make_game.py) run no such check and were not affected.
Reproduced before the change: kernel.c built with the ucrt64 clang at -march=x86-64-v2 failed tools/check_isa.py with "kern_ap_stub_ymm ...: vmovdqa x32, vzeroupper x1", and passes now. Checks that passed: the new tests/check_isa_test.py (kernel.c and sha256.c built with -march=x86-64-v2 and without -march pass; an AVX function not in DISPATCHED still fails; it fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/check_platform.py scanned the raw text of each shared runtime file and knew nothing of preprocessor branches, so the Win32 use that input.c, mods_probe.c, rtss_limit.c, vk_cache_file.h, vk_dlaa.c, vk_dlssg.c, vk_dump.c, vk_ffx.cpp, vk_ffx4.cpp, vk_fg.cpp, vk_fg4.cpp and vk_pipeline.c keep behind #ifdef _WIN32 made it fail on every run, at f1a2a64e already: a check that always fails guards nothing. It now leaves out the lines of a Windows-only branch (#ifdef _WIN32, #if defined(_WIN32), #if _WIN32 up to their #else, #elif or #endif, the #else of #ifndef _WIN32 or #if !defined(_WIN32), and conditionals nested in them), found on the text with its comments blanked. Every other branch is still scanned, #elif defined(_WIN32) and #if defined(_WIN32) && X among them, which can fail the check but never lets a use through. docs/building.md lists the check and its test under "Checks".
Reproduced before the change: the check exited with 1 and these 12 FAIL lines on 2f640fb8; it now exits with 0. Checks that passed: the new tests/check_platform_test.py (guarded uses pass, unguarded ones fail, the repository's runtime passes; it fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`launcher.cli update` downloaded the release and switched to it without asking whether the game ran. The new version's continue-update then failed in its rebuild with "the game is running", which left the new launcher with the old build until Rebuild the Game. The window's App.start_update refuses first; the command now does the same before its update check, and ends with exit code 1 and "error: the game is running: close it first, the update rebuilds it".
Not reproduced before the change: found by reading the code. Checks that passed: tests/launcher_test.py (217 tests, a new one in which a running game stops `update` before its check and its download, which fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Steam shortcut's --play runs before the window's single-instance lock and checked only whether the game was running. Started while the window rebuilt the game, installed an update or built the mods' code, it ran mods.prepare into the folder the job was reading and started the old ocarina.exe, and the job then failed at the swap with "ocarina.exe is in use" (an update's install left the new launcher on the old build); during the shaders step, make_game.py's own run of the exe made the shortcut log "already running" and do nothing visible. play_only now tries the launcher folder's lock and lets go of it at once: when another process holds it and data/job-running.json names a job (jobs.running_job, which leaves the marker in place), it logs that the launcher is busy, says which job to wait for in a message box and does not start the game. A marker that a stopped launcher left does not stop it, since its lock went with its process, and neither does the window open without a job.
Not reproduced before the change in a game run: found by reading the code. Checks that passed: tests/launcher_test.py (216 tests, a new one for a held lock with the job's marker, a leftover marker with the lock free, and the window open without a job, which fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build.game_running opened the exe for writing and took every PermissionError that the read-only attribute did not explain for a running game. Python reports a sharing violation (a running image) and access denied (an access list that denies the player writes, or Controlled Folder Access switched on for a game folder under Documents) as the same PermissionError, so an exe that nothing ran but the player could not write kept Play answering "The game is already running", the Steam shortcut doing nothing and a rebuild failing with "the game is running". The open now goes through CreateFileW, with every share mode granted, and reads its error: 32 (ERROR_SHARING_VIOLATION) is a running game; 5 on a read-only exe clears the attribute and tries again as before; any other access denied is no running game, and the rebuild's check logs that the exe cannot be opened for writing, so that make_game.py's later failure to replace it has its cause beside it in launcher.log. The game's own profile lock is not probed instead: a handle on it, even for a moment once a second, makes a starting game end with "another copy of the game is running".
Not reproduced before the change in a game run: the audit's probes showed it on a scratch exe with a deny-write access entry. Checks that passed: tests/launcher_test.py (215 tests, a new one for errors 32, 5 and 0, a read-only exe and the real CreateFileW call on a free file and on a running sleeper compiled with the ucrt64 clang) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
play_only, the --play path of the Steam shortcut, logged only "the game ended (code N)" in launcher.log: it read no FATAL line, added no note to the game's log and showed nothing, so a game that ended with 70, 71, 72 or a Windows status left the player with no word of why (as when Steam starts it while the window replaces a save, and it ends at once with "another copy of the game is running"). The 7b0dbeb1 change that logs every end touched the window's watch only. play_only now follows the run with GameRun, the Restart's copies too, and takes the same game_end as the window: the line in launcher.log, the note in the game's log, and a message box only for an end worth one (a FATAL line, 70, 71 or 72, a Windows status; not 0, nor 1 from Task Manager's End task). The logs are written before the box, which waits for OK while Steam still counts the game as running.
Not reproduced before the change in a game run: the audit's probe showed it with a stand-in process. Checks that passed: tests/launcher_test.py (214 tests, a new one for the Steam shortcut's ends with codes 70, 0xC0000005, 0 and 1, which fails before the change) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The launcher read the game's FATAL line only after codes 70 and 71, so a run that ended through vk_device.c vk_device_lost_check (code 72, as in the AMD device losses of 2026-11-12) showed a bare "code 72" with no cause. 72 now joins 70 and 71 in FATAL_CODES and has a text of its own, which stands without a FATAL line because the loss's watchdog (vk_device.c lost_wait_exit) can end the run before that line is written.
The game's Restart (gfx_present.c gfx_window_closed) starts the exe again and ends the process the launcher started with code 0, and the launcher took that 0 as the run's code. A restarted copy that ended with 70, 71 or 72 was logged as an end with code 0 and its FATAL line was never shown; one that was killed or hung got a note saying code 0 in its log. Once the process it follows has ended with 0 while the exe still runs, the launcher now finds the copy by its image file (winsys.game_copy, compared as a file so that another install's game is not taken), holds a handle to it and takes its exit code; a further Restart is followed the same way. A copy it could not find ends with its code logged as not known. After code 0 or an unknown code, a FATAL line in a log written since the start can only be such a copy's, and it is shown as one; a log older than the start (a run that ended before it opened its own) is not read. A log cut at its 64 MiB cap (main.c log_sink) wrote its end lines to stderr only, and no longer gets a note saying it has no line of its own end. The end's handling is one function, game_end, for the Steam shortcut's play to share.
Not reproduced before the change in a game run: the audit's probes showed each of these ends with stand-in processes and logs. Checks that passed: tests/launcher_test.py (213 tests, new ones for code 72 with and without its FATAL line, the followed copy, a copy not found, a FATAL line after code 0 and a log older than the start, the cut log, and game_copy on a sleeper compiled with the ucrt64 clang) and tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the ExitProcess note found that platform/win32.c plat_exit_note_modules, which runs on the window thread
at every focus loss, was quadratic in the number of modules: GetModuleInformation and GetModuleBaseNameA each walk the
loader's module list through ReadProcessMemory. Its probe measured 27.5 ms a note with 292 modules.
Each module's size now comes from SizeOfImage in its own image header and its name from GetModuleFileNameA, the part
after the last backslash. The header is read through ReadProcessMemory into a 1 KiB buffer rather than in place: a
module unloaded since EnumProcessModules then fails the read and is left out, where a plain read would fault. The
table's contents are the same.
Measured with a probe beside this change (283 modules loaded, scratchpad rv_fxexit_fix\modtime_rpm): 25.3 ms a note
before, 0.27 ms after, and both ways note the same 283 bases, sizes and names. tests/exit_tls_check.py, whose note
names the program's own module from that table, and the compile check of win32.c pass, with no new -Wall warnings.
The review's optional second note after the device is made is left out: vk_device.c create_device is another
branch's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the hang watch found that kernel.c kern_wait_report called rt_log for up to 16 lines while it held the
kernel lock G. When the log queue is full and the log thread is stuck holding its write lock, one of the stalls the
watch reports, the put that drains the queue waits there with G held, which turns a log stall into a freeze of every
guest thread. wait_watch (NX2NAVI_WAIT_WATCH_S, off by default) had the same pattern.
wait_pass now formats its lines into a buffer of the caller's (WAIT_LINE_CAP bytes a line, room for the 16 or 24 lines
asked for), and kern_wait_report and wait_watch log them with the new wait_lines_log after unlock. A line that finds
no room ends the pass unmarked, so wait_watch logs it at its next pass. The lines read as before.
Not reproduced before the change (it needs a stalled log thread beside a report). tests/hang_watch_check.py checks
statically that wait_pass logs nothing and that both callers log only after unlock; the check fails on the code
before this change and passes after it, and the program's cases pass. The compile check of kernel.c passes with no
-Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the exit sequence found that svc.c case 0x07 logged "guest ExitProcess at ...; exiting" before
rt_exit_begin. When a close had already begun, the guest thread that the guard then parks still claimed to exit, and
past the log cap the line fell outside the end's reserve, which rt_exit_begin opens. It also found that host.h and
gfx_wsys.h still said gfx_window_closed does not return, though it returns for its own thread coming back, and that
rt_exit_begin's comment spoke of the window thread handling a message inside the sequence, which since the close
runs on a thread of its own happens only in the fallback.
svc.c logs the line after rt_exit_begin returns 1. The two header comments say gfx_window_closed returns only for its
own thread coming back (gfx_present.c rt_exit_begin), and rt_exit_begin's comment says the window thread runs the
sequence itself only when gfx_window_win.c exit_start could make no thread.
tests/exit_order_check.py checks statically that 0x07's rt_log comes after rt_exit_begin; it fails on the code
before this change and passes after it. exit_order_check and the compile check of every runtime file (a header
changed) pass, with no new -Wall warnings in svc.c and gfx_present.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the shared failure guard found that a thread parked in dispatch.c rt_ending_enter ended the process
with 71 after 30 s even when the first report was a FATAL (70, or 72 for a lost device), so a fatal error whose report
hung read as a crash to the launcher.
rt_ending_enter now takes the code its caller ends with: fatal_end passes its own, main.c crash_handler 71. The first
caller stores it next to ending_tid, and a waiting thread that gives up after 30 s ends the process with it. Before
the store the value is 71, as before.
Reproduced before the change with a one-off build of tests/fatal_exit_check.c given a fatal note that never returns
and a fault on another thread: the process ended with 71 after 30.5 s; with this change it ends with 70 after 30.5 s.
That case takes 30 s, so the matrix gets a static check instead: tests/fatal_exit_check.py holds rt_ending_enter to
storing the first caller's code and ending with it, fatal_end to passing its code, and crash_handler to its first
line `if (!rt_ending_enter(71)) plat_exit(71);`. fatal_exit_check and the compile check of every runtime file (a
header changed) pass, with no new -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the RTSS thread change found that only main.c crash_handler skipped rtss_limit_restore on the "rtss"
thread. An rt_fatal on that thread while it holds rtss_limit.c's lock still called the restore from dispatch.c
fatal_end, whose trylocks then spent a second for nothing before the end went on. A-14 names fatal_end.
fatal_end now reads plat_thread_name and calls rtss_limit_restore only when the thread is not "rtss", as
crash_handler does.
Not reproduced in the game before the change. tests/fatal_exit_check.c's stand-in restore now says it ran, and a new
fatal_rtss mode calls rt_fatal on a thread spawned with the name "rtss": on the code before this change the restore
ran there (the check fails), now it does not, and the process still ends with 70 after the rumble stop. The other
cases check that the restore runs from any other thread. fatal_exit_check and the compile check of dispatch.c pass,
with no -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the fatal summary change found that dispatch.c fatal_end called perf_summary_flush_try (ft_stop waits up
to 50 ms, then the summary and the stutter map are formatted) before rtss_limit_restore and rt_rumble_stop_bounded.
The lane's order for the failed ends, and A-6's for the orderly ones (gfx_present.c rt_exit_steps), puts what a kill
of the process must not lose first: RTSS's profile and the controller's rumble.
perf_summary_flush_try now runs after the restore and the bounded rumble stop, just before the stdio flushes and
plat_terminate. The summary's lines still reach the log: rt_log_sync ran at the start of fatal_end, so every later
line is written as it is made.
Not reproduced before the change (an order of steps). tests/fatal_exit_check.py checks statically that fatal_end
calls perf_summary_flush_try after rt_rumble_stop_bounded; the check fails on the code before this change and passes
after it, and the program's cases (fatal, fatal_rumble, crash_crash, fatal_crash, crash_fatal) pass. The compile
check of dispatch.c passes with no -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the log tail change found that main.c log_sink opened the 4 MiB reserve past the cap on g_log_final as
well as on g_log_ending. bugcap.c write_log_tail calls rt_log_sync in play, which sets g_log_final, so after a bug
capture in a trace = perf session past the cap, ordinary play lines filled the reserve (about 6 minutes at 12 KB/s),
the end's lines were dropped again, and the "end of the run follows" line came mid-run.
The reserve is now keyed on g_log_ending alone, as E-10's design had it. The ends that relied on rt_log_sync now call
rt_log_ending first: dispatch.c fatal_end and main.c crash_handler right after their guard, vk_device.c
lost_wait_exit's watchdog before its sync, and plat_exit through the new main.c log_sync_ending (plat_exit_hook in a
game run), so every path that ends through plat_exit marks the end too. rt_exit_begin and perf_summary_flush call it
as before.
Not reproduced in the game before the change. tests/log_cap_check.py gains a capture case (rt_log_sync in play, 1000
more play lines, then the end): on the code before this change 58 play lines took the reserve, the end line came at
the sync and no end line reached the file; now no play line passes the cap and the end's lines fill the reserve. A
fatal case runs rt_log_ending and then rt_log_sync, and static checks hold crash_handler, log_sync_ending, fatal_end
and lost_wait_exit to calling rt_log_ending before rt_log_sync. log_cap_check, fatal_exit_check (with a stand-in for
rt_log_ending) and the compile checks of main.c, dispatch.c and vk_device.c pass, with no new -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the save file use count found that fs_host.c file_release took `lock` exclusive whenever users dropped
to 0, which is the end of every single read_file, write_file, file_size, set_file_size and flush_file, not only when
a CloseFile had marked the slot. The exclusive acquisition waits for every shared holder, such as the fsync loop of a
non-save Commit (commit, FlushFileBuffers under the shared lock), and while it waits it holds up new file_of callers:
a new wait on the guest threads that the change had said it did not add.
file_release now takes the lock only when it finds closing set after its decrement. The decrement and the load of
closing, and close_file's store of closing and load of users, are sequentially consistent pairs, so either the last
call out sees the mark or close_file sees users at 0 and closes the file itself; the check under the lock still keeps
the close to one. closing is an int32_t read and stored with atomics; a slot is emptied by the new file_clear, which
stores users and closing as atomics because the call that has just left the slot may read closing without the lock,
and open_file fills only h, mode and save.
Not reproduced in the game before the change. tests/fs_file_use_check.py gains a case: 1000 reads with no close take
the exclusive lock 0 times, and a read ends while another thread holds the lock shared. It failed on the code before
this change (1000 exclusive takes, the read waited) and passes after it, as do its close-during-a-read case and its
20000 closes and opens beside a reader (six runs, no foreign bytes, no double close). The compile check of fs_host.c
passes with no -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the window close change found that hiding the active window in gfx_window_win.c exit_start makes
Windows send WM_ACTIVATEAPP(FALSE), which wnd_proc passed on to gfx_window_focus_changed(0). In play, perf.c
perf_focus_lost then asked for the session's reports, and the present thread, which runs until rt_exit_steps reaches
prof_write, wrote "frametime: at focus loss" for the whole session at its next present, with the stutter map and the
memory ranking, and started a new period. "frametime: exit" then covered only the few frames since the hide, or was
missing when none came, so a normal close no longer left the exit summary the play-test's "Normal close" item reads.
wnd_proc now returns at a focus loss once g_exit_started is set. exit_start sets it on the window thread before
ShowWindow, and the message comes on the same thread inside that call, so the check has no race. A gained focus is
still passed on.
Not reproduced before the change (it needs a play run). tests/exit_order_check.py checks statically that
WM_ACTIVATEAPP returns before gfx_window_focus_changed for a loss once g_exit_started is set and that exit_start sets
the flag before the hide; the check fails on the code before this change and passes after it. The compile check of
gfx_window_win.c passes with no new -Wall warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two runs of 2026-11-13 (the 0.10.0 fresh install's nx2navi.1.log, 7,974,927 bytes, and the 0.9.2 test's
nx2navi.3.log) ended seconds after "window lost the focus" with exit code 0, no WER event and no line of their own:
an ExitProcess or exit() called by a module in the process and a kill from outside could not be told apart, and
both lose the up to 250 ms of lines the log thread was gathering. A direct ExitProcess skips perf.c's atexit sync.
(a) ExitProcess stops every other thread and then calls each module's detach, the executable's TLS callbacks
included; TerminateProcess calls none. win32.c exit_note_tls, in the .CRT$XLB list beside _tls_used, writes on
DLL_PROCESS_DETACH what the log queue holds (logq.c logq_peek: the bytes as they stand, no lock) and one line,
"exit: the process is ending through ExitProcess, not one of the game's own ends", with the return addresses that
led there. It runs under the loader lock with the other threads gone, so it uses no C runtime (hex written by hand)
and does not ask the loader: the addresses are named from a module list noted at start and at each focus loss
(plat_exit_note_modules: EnumProcessModules, two tables and a writers' lock the callback never takes), and
RtlCaptureStackBackTrace and WriteFile take no lock of the process. plat_exit, plat_terminate and plat_reexec set
the orderly flag (plat_exit_orderly), and main does once main_run has returned, so a tool mode's exit() leaves no
line; a library's exit() during a game run does, naming ucrtbase and its caller. main.c log_open sets it up for a
game run that writes a log file. Linux has empty stand-ins.
(b) gfx_window_focus_changed calls the new main.c rt_log_focus_lost at a focus loss: logq_nudge makes the log thread
write what it gathers at once (a flag log_main's gathering loop checks) and the module list is noted again.
Not reproduced before the change (the two ends are unexplained). The new tests/exit_tls_check.py links win32.c and
logq.c with a queued line still being gathered: ExitProcess from the program and exit() write the line and the note
naming exit_tls_check.exe; TerminateProcess, plat_exit and a return after plat_exit_orderly write no note.
tests/logq_check.c gains a nudge case, which fails with the flag left unset (252 ms) and passes with it.
thread_end_check, fatal_exit_check, crash_stack_check, exit_order_check, save_share_check and the compile checks of
every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.c foreign_hook matches "hook", so a crash in RTSSHooks64.dll got the line that the library is not part of the
game and that a recording, overlay or capture program put it there, also when the game's own thread that sets the
frame limit (rtss_limit.c's "rtss" worker) had loaded and called it. The one RTSS crash on record (issue #231)
faulted in ntdll on that thread, where no note applied. And the crash handler then called rtss_limit_restore from
that thread, whose own lock it holds (SRW locks are not recursive), so the restore's trylocks spent a second.
The platform layer keeps the name a thread was made with in the thread itself (plat_thread_name, copied at its
start: a detached thread's struct may go before it ends). main.c crash_module_note picks the note: on the "rtss"
thread, whatever the module, the line says the fault is in the game's calls of RTSS's library or around them and
that rtss = 0 keeps the game from calling it; on other threads RTSSHooks64.dll stays foreign, as it is RTSS's
overlay there. The restore is skipped on the rtss thread. rtss_limit.c is unchanged: the thread is known by the name
it is spawned with. Crash text only, besides the skipped restore.
Not reproduced before the change. tests/foreign_hook_check.py checks the note's choice (RTSSHooks64.dll and ntdll on
the RTSS thread: the RTSS note; RTSSHooks64.dll elsewhere: foreign; a driver: none), and tests/crash_stack_check.py
that the crash report reads the faulting thread's name. They, thread_end_check, fatal_exit_check and the compile
checks of every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.c foreign_hook matched "vk_layer", "hook", "overlay" and "reshade" in the lower-cased module name, so a crash
inside a layer named in the usual VkLayer_<name>.dll way (lower-cased "vklayer_") or inside obs-vulkan64.dll or
nvspcap64.dll lacked the line that the module is not part of the game (the audit's probe on these names).
It also matches "vklayer", "obs-vulkan" and "nvspcap" now, and leaves out "vklayer_khronos": the validation layer is
one the game loads itself when NX2NAVI_VK_VALIDATION asks, and its crash should not tell a developer to close a
recording program. Crash text only.
Reproduced before the change from the probe's names; the new tests/foreign_hook_check.py cuts foreign_hook out of
main.c and checks nine foreign names and ten of the game's own and the drivers' (the validation layer among them).
It and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
win32.c con_ctrl returned FALSE for a second Ctrl+C, Ctrl+Break or close while the first one's end ran, so the next
handler, the default one, ran ExitProcess at once: a save Commit in flight was cut (the next start rolls it back)
and every library's detach ran. Only a game started from a console has the handler; the launcher starts it with no
console.
The second event now waits for a Commit under way through fs_save_crash_wait (2 s at most, no lock; weak in win32.c
for builds without fs_host.c, as dispatch.c has it) and ends the process with plat_exit and STATUS_CONTROL_C_EXIT, the
code the default handler gives. The force-quit stays; it takes up to 2 s longer when a Commit runs.
Not reproduced before the change (a play-test item from a console: Ctrl+C twice). By review; fatal_exit_check,
logq_check and save_share_check, which link win32.c beside a weak or a strong fs_save_crash_wait, and the compile
checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gfx_window_win.c wnd_proc ran gfx_window_closed, which does not return, on the window thread for WM_CLOSE and
WM_ENDSESSION. While pipeline builds run the end takes seconds (vk_pipeline_save_exit waits up to 5 s), and the
window stopped pumping meanwhile: after 5 s Windows showed it as not responding, and an "End task" there killed
the process in the middle of its saves.
WM_CLOSE now starts an "exit" thread that runs gfx_window_closed (exit_start; the end runs on the window thread as
before should no thread be made) and returns 0, so the pump goes on. The window is hidden as the end starts, so a
window that still answers does not invite a second close; one would find the end under way in rt_exit_begin.
WM_ENDSESSION(TRUE) starts the same thread and waits for it on the window thread, since Windows ends the process
once that message returns; after a close had started the end it waits as well. The SDL window is unchanged.
ShutdownBlockReasonCreate was left out: called at WM_ENDSESSION it is too late to show a reason.
Not reproduced before the change (a play-test item: close on the boot screen, the window must not ghost).
tests/exit_order_check.py checks statically that WM_CLOSE and WM_ENDSESSION go through exit_start, which hides the
window, and that the session's end joins the thread; it and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cheats.c rebuild_if_needed runs in rt_boot after the prewarm thread has started, and ended the process with only
prof_write and rtss_limit_restore. By then vk_pipeline.c may have written pipeline-prewarm-running.txt (pm_set) and
opened the boot's journal of library builds; left in place, the next start took the rebuild's end for a driver crash
in the boot's builds (pm_skipped counts it as a quiet end; three of them skip the prebuild). The rebuild.skip file
keeps it to one quiet end per rebuild.
The rebuild's end now takes the orderly end's guard (rt_exit_begin), puts RTSS's profile back, clears the prewarm
marker, closes the boot's journal through the new vk_pipeline.c vk_pipeline_boot_abandon (journal_close, as
vk_pipeline_save_exit does), writes the summary and ends. No save is mounted and no menu has run yet, so the saves and
nx2navi.ini have nothing to write. A narrow race stays: pm_set writes the marker and then sets pm_on, so a clear in
between leaves the file; the window's close has the same one.
tests/exit_order_check.py checks that order in rebuild_if_needed. Not reproduced before the change;
exit_order_check and the compile checks of every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
cfg.c default_number, which cfg_int and cfg_double use for a set value that does not parse, read cfg_meta(name)->def
without a check, so a key without a row whose value was set and unparsable crashed there (the verifier's probe: a
segmentation fault inside default_number). gfx_present.c gfx_cfg_window_size did the same for WINDOW_SIZE. Neither
is reachable today: every unguarded value read has a row (tests/cfg_rows_check.py holds it so), the three guarded
ones use cfg_bool, and WINDOW_SIZE has a row.
default_number gives 0 for a key without a row; gfx_cfg_window_size falls back to 1920x1080 when the row or its
default is missing. Taking the device profile's default there (1280x800 on a Steam Deck) would change what players
get and is left for approval.
Reproduced before the change with the new case in tests/cfg_parse_check.py (cfg_int and cfg_double of an unset row's
key set to "abc" crashed the program); with the change both give 0 and the check passes, as do the cfg rows checks
and the compile checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cfg.c keeps at most CFG_MAX_KEYS (512) keys. add, cfg_set, cfg_reset and migrate stopped silently at the cap, so a
file with more keys (every cheat ever switched keeps a cheat_<hash> line, unknown keys count too) had its later keys
ignored and a later menu change lost, with nothing in the log (the audit's probe: low_latency after 600 cheat lines
read as unset, a cfg_set of vk_present lost, 0 log lines). Only a file far past what players have reaches it.
A line now says so: at load once per load, naming the first key and line not read, and once a run for the changes
cfg_set, cfg_reset and migrate could not keep. Raising the cap would change which keys take effect and is left for
approval.
Not reproduced before the change by a game run; tests/cfg_parse_check.py gains the case: a file of 600 cheat keys
and then low_latency gives one line at load from line 513 on, and two cfg_set past the full table one line. The cfg
rows checks and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A present, a submission or a completion that never came back (a deadlock, a driver wait that does not return, a
GPU hang without a reset) left a log that just stopped: kernel.c wait_watch covers guest lock and thread-end waits
only and is off by default, and nothing watched the present, submit or completion threads.
hang_watch.c runs a thread of its own in a game run (main.c starts it before the boot; never in the tool modes).
Once a second it reads, without a lock, gfx_counters.frames and the submissions issued and done (the new
vk_sync.c vk_sync_progress, no device call). It reports a stall of the frames while the guest runs, the window is
in front and not minimized (gfx_window_in_front) and no orderly end has begun (rt_exit_started): the game stops
presenting without the focus, so a focus change starts the count again, and the boot screen is not judged. It
reports submissions issued and not done, "done" unchanged for N s, whatever the focus. One report per stall, a
new stall needs progress, at most 3 a run. A report names the stall with the counts, the focus and the world
clock; who holds the present, queue and window-start locks (gfx_present.c gfx_lock_holders, read from
PlatRMutex.owner and depth); the guest threads' lock and thread-end waits (kernel.c kern_wait_report, wait_watch's
walk factored out as wait_pass, run with limit 0 under ten 10 ms trylocks of the kernel lock); and then writes the
log out with logq_flush, never rt_log_sync, which would put every later line on the thread that logs it in a run
that may recover. It never ends the process and adds no wait to a drawing thread. The per-thread wait sites and
lock owner wrappers are left for a later change.
NX2NAVI_HANG_WATCH_S sets the seconds (default 20, 0..600, read at start, 0 off): a metas row, a known[] name, a
diagnostics sections[] entry and an nx2navi.ini.example entry.
Not reproduced before the change (a diagnostic). The new tests/hang_watch_check.py drives hang_watch.c's decisions
on made-up readings: no report while things move, unfocused, minimized or ending; one per stall of the frames with
the window in front and one again after progress; one for submissions not done whatever the focus; none for an
idle GPU; at most 3; logq_flush called and logq_sync never. tests/cfg_rows_check.py (0 problems), the scratchpad
rows check ("missing []") and the compile checks of every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A // on a metas[] line hides the rows after it on that line, and the game then ends at start with "FATAL: cfg:
NX2NAVI_<X> has no row in the settings table"; that shipped in two test builds and nothing caught it before a
start. And 15 names the runtime reads were not in known[], so setting any of them in nx2navi.ini logged "unknown
key" and put it in the menu's list of unknown keys although it took effect: AUDIO_DUMP, DEFER_STAMP, DEPTH_TWIN,
DLAA_ZFIT, DUMP_SKIP_RUNS, DUMP_TEX, DUMP_UBO, EVENT_ORDER, GATHER_BIAS, PIN_SWEEP, REC_CHECK, REC_DELAY_US,
REG_ORDER, SHADOW and ZERO_LOCALS (GATHER_BIAS and ZERO_LOCALS through vk_caps.c env_u32).
tests/cfg_rows_check.py (standard library only) parses known[], metas[], sections[], DECK[] and LOWEND[] by
brace matching that skips string and character literals and comments (an apostrophe in a comment inside a table
would otherwise open a character literal and run the table on past its end), line by line with the first // outside
a string split off, and the first #if branch standing for the table. It fails on a row or name after //, a /* in a table, a name twice in known[], two metas[] rows for a
key, a metas[] row not in known[], a cfg_value/cfg_int/cfg_bool/cfg_double read (directly or through one level of
wrapper) with no row and no cfg_get, cfg_is_set or cfg_meta guard earlier in the same function (kernel.c SPIN and
SVC_WAITS, vk_headroom.c VRAM_HEADROOM pass that way), and a name read through cfg_get, cfg_is_set, cfg_meta,
rt_knob, a cfg_get or "NX2NAVI_%s" wrapper, or a patches-v111.txt set line that is not in known[] or a cheat.
--self-test puts each kind of fault into a copy and checks it is caught. The content guard workflow runs the
check after tools/check_content.py. The 15 names go into known[] on a line of their own with no //.
Reproduced before the change: the check reported the 15 names on stability-pass, as the audit's prototype did.
Now it reports 0 problems and its self-test catches all 15 injected faults; the scratchpad cfg rows check
("missing []"), cfg_parse_check and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.c log_sink wrote nothing to the file once g_log_bytes passed LOG_CAP: every later line, the exit summary,
"frametime: exit", the stutter map's exit lines and FATAL and crash lines included, went to stderr only, which a
game started from the launcher or Explorer does not keep. A trace = perf session reaches the cap in about 90
minutes (the fresh 0.10.0 log grew 12 KB/s), and the play-test asks for an hour or more of exactly that. A reserve
compared against g_log_bytes would not help: that counter adds every line, dropped ones too, so 4 MiB of dropped
lines (under 6 minutes at that rate) would use it up before the end came.
The new rt_log_ending marks the process as ending; rt_exit_begin (the orderly ends) and perf_summary_flush (the
frame-time summary, also on the cheats rebuild exit) call it, and rt_log_sync (a fatal error, the crash report)
counts as ending too. The exit summary and the stutter map are written before rt_log_sync, so keying on
g_log_final alone would have missed them. While ending, a line past the cap goes to the file through
g_log_tail, a counter of the bytes written past the cap, up to LOG_END_RESERVE (4 MiB), after one line saying the
end of the run follows. The cut line says the end will still come. The file can end 4 MiB past the cap.
perf_summary_flush now calls rt_log_ending before ft_stop, so tests/exit_order_check.py's static check of it wants
ft_stop before ft_report rather than first. The check also prints one closing line for the static checks and the
program together: the program's own "ok" line had hidden a failing static check.
Not reproduced before the change (no log here reached the cap). The new tests/log_cap_check.py builds log_sink
with a 4 KiB cap and a 2 KiB reserve: past the cap ordinary lines are dropped with one cut line, and after
rt_log_ending or rt_log_sync the end's lines fill the reserve after one "end follows" line, however much was
dropped before. exit_order_check (rt_exit_begin marks the end once; exit code 0 at this commit), logq_check and the
compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cfg.c takes a value as written and logged nothing about it: "pace_smooth = 0 ; off" keeps the comment in the value
and cfg_bool reads it as on, "fullscreen = false" is on too, and "sharpness = 250" is past 0..100 (the audit's
probe on cfg.c printed exactly that, with 0 log lines during the load). A player who edits the file by hand had no
way to see why a setting did not take.
After cfg_load (and after migrate, so a value it rewrites is checked as read) each file value is checked against
its row: a 0|1 switch with any other value, a value that is not a number in a lo..hi range, and a word that is
none of a list of words, case aside. A list with a number in it is left alone: it is the menu's presets and its
consumer takes more (fps_limit any rate from 30 to 240). A list of words is reported as "none of the menu's
choices" only, since a consumer may take more (dlss_preset a..m). A value holding " ;" or " #" is noted on any row:
the comment is part of it. cfg_init checks the environment's NX2NAVI_ values the same way. At most 16 lines a load,
then a count. The values read are unchanged; reading words for off or cutting comments (F-8) needs approval.
Not reproduced before the change by a game run; the new tests/cfg_parse_check.py builds cfg.c with stand-ins and
checks each kind of line with its line number, the environment's, the cap of 16 and a count, and no line for a file
whose values fit (including fps_limit 144, render_scale 300, dlss_preset k, pad_layout xbox_prompts). Every value
nx2navi.ini.example shows passes without a line. launcher_test PlayerIniTest, the cfg rows check and the compile
checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
input_rumble_hd_stop ran only in the orderly ends, so a FATAL or a crash while a controller vibrated on HD rumble
left it buzzing: nothing renews or stops the value after the process is gone (input.c says so). It also took
hd_lock with a blocking lock while it only tried the other two.
dispatch.c rt_rumble_stop_bounded runs input_rumble_hd_stop (weak in dispatch.c, as fs_save_crash_wait is, for
builds without input.c) on a new "rumble stop" thread and waits at most 300 ms for it, never joining it: the stop
goes through SDL, which takes its own joystick lock, and a crash on SDL's thread while it held that lock would
otherwise keep the ending thread there and the process would never end. fatal_end and main.c crash_handler call
it right after rtss_limit_restore. input.c tries hd_lock 50 times 1 ms apart, as it does `lock` and sdl_lock, and
skips the stop when it is not taken.
Not reproduced before the change. tests/fatal_exit_check.py (now linking dispatch.c as its own unit: the stop runs
at a fatal error; a stop that hangs leaves the end at code 70 after 0.3 s; statically both failed ends call the
bounded stop after the RTSS restore), tests/rumble_hd_check.py and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stack overflow leaves the faulting thread only a guard page's worth of stack for the handler, and the crash
report needs more: main.c crash_handler's rt_log buffers are 2 KiB each, it walks the frames, and while the boot
builds pipelines its first call writes a file (plat_file_write's frame is about 4 KiB). Nothing in the runtime set
a stack guarantee, so the report faulted again; the verifier's probe ended with 0xC0000005 and no line once the
report's first call was the file write. The launcher starts the game with stderr on DEVNULL, so raw_note's stderr
line was all there was, and nobody saw it.
The new platform plat_stack_guarantee calls SetThreadStackGuarantee with PLAT_STACK_GUARANTEE (32 KiB) and runs
at the start of every thread win32.c thread_main starts, in fiber_start (a guest fiber's stack is its own and the
guarantee belongs to the running fiber), and on the main thread in main. Each thread and fiber commits that much
more of its stack. Linux's crash handling already runs on its signal stack (linux.c alt_stack), so it is empty
there.
Reproduced before the change by the new tests/crash_stack_check.py: a plat_thread_spawn thread and a plat_fiber_new
fiber recurse until their stacks run out, and the handler writes a file and three 2 KiB log lines. Without the
guarantee both ended with 0xC0000005 and no line; with it both write every line and end with 71.
thread_end_check, fatal_exit_check and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fs_host.c file_of copied the slot's host file under the shared lock and let go of the lock, and read_file,
write_file, file_size, set_file_size and flush_file then used it with no lock, while close_file on another thread
cleared the slot and closed the file at once. Windows hands a closed HANDLE's value to the next file opened (the
audit's probe got it back in 100 of 100 rounds and read the other file through the stale value), so a call under
way went on in another file: a save write could land in the wrong one. Only the game's own misuse of a handle
reaches it; this is the open file half of AUDIT's F-1-09.
Each call now counts itself in its slot from file_of to a new file_release. close_file closes at once when no
call is under way and otherwise marks the slot closing; the last call out closes the file (file_closed, with the
save bookkeeping close_file had). Nothing waits for another call, the slot and so the guest's handle value is not
handed out again before the close, and a call after the CloseFile gets FS_R_INVALID_ARGUMENT, as before for a
closed handle. Holding the lock across the I/O was not taken: opens and closes on every guest thread would then
wait for save reads and writes, and write_file's save_touched can wait for a Commit.
Reproduced before the change with stand-in host files that reuse a closed handle's value: the new
tests/fs_file_use_check.py built against the old functions closed during a read, gave the next open the same
handle, and the read returned the other file's bytes. With the change it passes (a close during a read happens
after it, once; a call after the close is refused; 20000 close-and-open rounds beside a reading thread read no
foreign bytes), as do save_share_check, save_retry_check, save_zip_check and the compile checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The platform layer calls the crash handler on every thread that faults, without waiting for the others
(win32.c crash_veh, crash_filter), and main.c crash_handler ended any second entry with plat_exit(71) at once.
So a second fault during the first report cut it after its first lines, together with its wait for a save Commit
(fs_save_crash_wait) and RivaTuner's restore; the audit's probe showed it in 3 of 3 runs. fatal_end had no guard
at all and none shared with the crash report: a crash during a FATAL's end wrote a whole crash report whose
"FATAL: access violation" became the log's last FATAL line (the one the launcher shows) and replaced code 70 with
71, and a FATAL during a crash report cut it with code 70.
dispatch.c rt_ending_enter is the one guard for both ends: the first caller reports and ends the process; the
same thread again (a failure inside its own report) ends at once with its own code (crash_handler keeps
plat_exit(71), fatal_end plat_terminate(code)); another thread waits in 10 ms sleeps, logging nothing since it
may hold the log queue's lock, until the process ends, and after 30 s ends it with 71 should the first report
hang. fatal_end calls it first; crash_handler calls it in place of its own busy flag.
Reproduced before the change by tests/fatal_exit_check.py's new cases, which failed on the old guards: two
faults at once (report lines 2 and 3 missing), a fault during rt_fatal (a crash report after the FATAL) and
rt_fatal during a crash report (exit 70, the report cut). They pass now, with the earlier cases and the static
check that crash_handler starts with the shared guard; the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dispatch.c fatal_end and vk_device.c lost_wait_exit ended with _Exit, which in the C runtime is ExitProcess: it
stops the other threads wherever they are and then runs every library's DLL_PROCESS_DETACH on the ending thread
(the audit's probes show the exe's TLS callback getting it after _Exit and not after TerminateProcess). That is
the detach plat_exit already avoids, after NGX's faulted once at the end of a clean run. After a FATAL such a
fault would enter the crash handler and replace code 70 or 72 with a crash report and code 71; after a device
loss a driver detach that hangs would hold the process for good, since ExitProcess had stopped the watchdog too.
The new platform plat_terminate ends the process with the code and nothing else (no exit hook, no stream flush:
the failing paths have written their lines and flushed). fatal_end calls it after its existing log sync and
flushes. lost_wait_exit tells its watchdog (spawned with a non-NULL argument) from the threads that stop there:
after LOST_EXIT_MS the watchdog alone writes "vk: the end of the run did not finish in 10 s; ending it" through
rt_log_sync's bounded trylocks, and the others end 2 s later only should the watchdog not have started. Exit
codes stay 70 and 72.
Not reproduced before the change (no detach fault on a fatal path has been seen). tests/fatal_exit_check.py
(new: dispatch.c and the platform layer with a TLS callback; rt_fatal ends with 70, its FATAL line and no detach
call, which it showed with _Exit; and statically, no _Exit in runtime outside the platform layer, and both ends
call plat_terminate) and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window's close, the guest's ExitProcess and the end of a --seconds run each ran their own list. All three
waited for the pipeline save (up to 5 s for running builds, then the write) before they put RivaTuner Statistics
Server's profile back, stopped HD rumble, wrote the frame-time summary and flushed the save, so a console close,
a session end or an "End task" that killed the process in that wait left the game's limit in RTSS's profile (the
next run takes it for the player's own), a buzzing controller and no exit summary. ExitProcess also lacked
cfg_save and the rumble stop, and it and the window's close had no shared guard, so both could run at once and
write the summary twice while the present thread still added to it.
gfx_present.c rt_exit_begin is the one guard (the first caller runs; another thread waits for good; the same
thread again returns, and svc.c ends the process then rather than resume guest code). rt_exit_steps runs, in
order: fs_save_wait (a Commit under way finishes, none is refused), rtss_limit_restore, input_rumble_hd_stop,
cfg_save (not for --seconds), the prewarm marker clear, prof_write, vk_mem_exit_log, a non-final log flush
(the new logq.c logq_flush, bounded trylocks, queue left on), vk_pipeline_save_exit, and fs_save_flush last.
perf.c ft_stop keeps the present thread's frame-time updates out of the exit's summary and waits at most 50 ms,
on the exiting thread, for one under way. The restore relies on rtss_limit.c's closed flag to stay restored
while frames go on during the pipeline save. The log order changes: "frametime: exit" and the stutter-map exit
lines now come before the pipeline save's lines.
Not reproduced before the change. tests/exit_order_check.py (new: the step order, the once-guard with a second
thread, ft_stop's wait and bound, and statically that the three exits use the helper with the RTSS restore
before the pipeline save), tests/logq_check.py (with a logq_flush case), tests/save_share_check.py and the
compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the IPC domain change (E-12) found that tests/ipc_session_check.py still described only the session and
transfer-memory checks; the domain section was listed only in the header of ipc_session_check.c. The docstring now also
says that a domain frees closed objects and gives their ids out again, answers a closed id with a bad handle, never
closes its own id, and is kept while an object with its own handle lives. The check passes; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the IPC session change (E-1) found that the kern_handle_close line lost one space when kern_handle_hold
and kern_handle_copy were added above it, so its comment started one column before its continuation line and the
neighbouring declarations. The line is as it was before that change again. Whitespace only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the one-event-per-kind change (E-11) found a narrow window kept from before: change_event_list counts a
slot (atomic_fetch_add on n_change_events) before it stores the handle there. audio_channels_live on the present path
running between the two read the slot's 0 and passed it to kern_event_signal, which ends the process with "not a
writable event handle". It needs a kind's first request to meet a channel-setting change.
change_event_list now stores the handle with release order, and audio_channels_live loads each slot with acquire order
and skips a slot that still reads 0; that slot's event is signalled by the next channel change.
Not reproduced before the change in a game run. tests/audio_mixer_check.c counts a slot without storing it and changes
the channels: no signal may reach handle 0, and the listed events are signalled again. Against the previous
audio_mixer.c it fails with handle 0 signalled once. The check passes, and the compile check of audio_mixer.c (also with
-Wall -Wextra) adds no warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the system-event change (Ev-1) found the table still filling. Entries in host memory now count as live
for good in the full-table scan, but their objects are freed: ov_movie2.c ov_dtor freed the Video with in_ev and out_ev
still bound, so each decoder left two dead entries that nothing reclaimed (AUDIT F-1-08 measured about two entries per
reload of a save, which E-4's verifier tied to the decoder pair), and their four kernel handles stayed open. A long
session would end with "kern_bind_system_event: table full" once no dead guest entry was left to take.
kernel.c gains kern_unbind_system_event(c, ev). On a guest thread it calls the SDK's nn::os::DestroySystemEvent, which
zeroes the state byte and closes both handles, and then removes the entry under g_se_lock (the last entry takes its
place). The destroy comes first, so a signal still in flight finds the event destroyed or its handle no longer naming it
and skips it, rather than finding no entry and binding again on a host thread. Off a guest thread, or when the SDK lacks
the function (logged once), only the entry goes. ov_dtor calls it for each bound event after finalize has joined the
decoder and convert threads, then frees the Video. net_offline.c frees its HostTask with a bound event in the same way;
that file is outside this change and is handed to the coordinator.
Not reproduced before the change in a game run. tests/system_event_check.c fills the table with live guest events and
the movie's two host-memory events, unbinds one: it leaves the table, its handles are closed and its state byte is 0,
the other keeps its entry, and a second unbind changes nothing; with the table full again and the last host entry
unbound, a new guest bind succeeds where it would have ended with "table full". The check passes in both modes,
movie_planes_check passes, and the compile check of every runtime file (host.h changed) passes, adding no warnings in
kernel.c or ov_movie2.c with -Wall -Wextra.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found the copy_retire text inaccurate: it said the thread reads 16 bytes of game memory per copy every 5
seconds. The sweep (nvn_api_cmd.c retire_pass) fingerprints each new copy once and visits at most 4096 known copies per
5-second pass. The text now says so. No code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>