17 Commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -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
66c607414b End a hung fatal error's report with its own code when a thread waiting beside it gives up, not with the crash code.
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>
2026-11-13 18:43:24 -05:00
chrg6
e6a6d6a809 Skip RivaTuner's restore at a fatal error on the RTSS thread too, not only at a crash there.
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>
2026-11-13 18:43:24 -05:00
chrg6
78e9d23211 Write a fatal error's frame-time summary after RivaTuner's restore and the rumble stop, not before them.
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>
2026-11-13 18:43:24 -05:00
chrg6
313ef09081 Open the log's end reserve only when the process is ending, not at a bug capture's log sync in play.
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>
2026-11-13 18:43:23 -05:00
chrg6
c040ca6c5f Write the frame-time summary and the stutter map at a fatal error too, the map only when its lock is free.
dispatch.c fatal_end wrote only the profiler's samples, so a run that ended in a FATAL had no "frametime" or
stutter-map lines for its last period: the numbers that would show what the game was doing before it failed.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:23 -05:00
chrg6
87fd385b7b Stop HD rumble at a fatal error and a crash too, on a thread of its own the end waits for 300 ms at most.
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>
2026-11-13 18:42:10 -05:00
chrg6
1352b14f64 Let the first failure report end the run whole when another thread crashes or hits a fatal error beside it.
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>
2026-11-13 18:42:10 -05:00
chrg6
8f952d2841 End fatal errors and a lost device with TerminateProcess instead of _Exit, which ran every library's detach.
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>
2026-11-13 18:42:10 -05:00
chrg6
534591be04 End a run whose GPU device is lost in one place: one report, no callbacks of unfinished submissions, and exit code 72.
A lost device used to end the process from whichever submit or wait noticed it first, while the present only logged it
and went on and the other threads kept calling the driver. The completion thread could take the submissions' timeline
as reached and run the callbacks of submissions that never finished.

vk_device_lost_check now takes the result of every submit, present, acquire and wait. The first thread to see the loss
sets a flag, writes the report and ends the process as a fatal error does (the log written, a save commit under way
finished), with an exit code of its own; later threads stop in that call, and a watchdog ends the process after ten
seconds should the first thread hang. The replay, its submit, the uploads, the present and the lazy fetch read the flag
and return without touching the device. The completion thread asks the device with a call of its own before a new
value counts and runs no callback once the flag is set.

The executable's --probe-devlost and tests/device_lost_check.py check this without a lost device, and
device_lost_at in nx2navi.ini ends a real run the same way.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 04:35:30 -05:00
chrg6
7ccb958c47 Let a save Commit that is under way finish before the crash report or a fatal error ends the process, for at most two seconds.
The game's Commit returns once the commit is queued, so the game may already have reported the save as done while its thread still copied; ending then left an unfinished copy that the next start rolled back.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:26:01 -05:00
chrg6
caf50ecf1c Turn the lazy write-back off by default on AMD's driver for RX 5000 and 6000 cards, and say in the log which settings a run had.
A player on an AMD card reported crashes in fights and that a line added to nx2navi.ini cured them. That line, async_compute = 1, is no setting of the game, and neither are two more in the same file; the one line of the file that changes how memory reaches the GPU is wb_lazy = 0. A second report, an RX 6600 on 0.7.1, lost its device 3.5 minutes into play with the lazy write-back on. Neither proves it the cause: the player changed several lines at once and sent no log, the RX 6600's last pass mark is inside a draw pass and not a fetch, cards with 8 GB lost their device the same way on NVIDIA before the lazy write-back existed, and reading the fault handler, the fetch, the arming and the buffers' lifetimes found no failure path. What depends on the driver and cannot be checked without the card: pages of guest memory are inaccessible while a host-pointer import of them is bound and written by the GPU, and Vulkan calls run inside the access-fault handler.

The default is now decided once the driver is known (wb_lazy_choice.h, vk_memory.c vk_mem_lazy_decide): on under Windows as before, except on AMD's driver branch for RDNA 1 and 2, which none of us has run it on; AMD's driver for RDNA 3 and newer keeps it on, since builds with that default were measured on an RX 7900 XT. What the player sets wins everywhere, and one line at start says the choice and its reason. On those cards open areas run at the 0.7.0 frame rate again (on an RTX 5090 that was 65 fps against 90 in Hyrule Field; not measured on AMD). tests/wb_lazy_check.c checks the choice by platform, driver and setting without a GPU.

For reports of this kind the log now holds what was missing. At start: every setting nx2navi.ini sets, and in one line the keys that are no settings of this version and do nothing; the settings menu shows those in its bottom row. At a fatal error or a crash: one line with the card, its driver, the memory mode, the lazy write-back with its reason, armed ranges, fetches so far and a fetch in progress, and the texture detail (dispatch.c rt_fatal_note_add, vk_device.c fatal_note), since a report is often the end of a log alone.

--probe-cfg loads the player's file: the three lines are named, and the menu's save keeps them and every other line it does not change byte for byte. Nothing here ran on an AMD card, and no fatal error was provoked to read the new line.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 15:14:25 -05:00
chrg6
a03e059d2b Write the log from a thread of its own, so a stalled write no longer holds the thread that logged.
Every log line was two writes on the thread that logged it, to stderr and to the log file. Such a write can stall for
tens or hundreds of milliseconds (a busy disk, a virus scanner, a console or a pipe that is slow to take it), and the
render thread logs too: a frame it spent in a write was a hitch with nothing else to show for it.

In a game run the lines now go into a queue that a log thread writes out in order (runtime/logq.c). A fatal error,
the crash report and the end of the process write the queue out on their own thread first and write their own lines
directly, so nothing is lost to an exit. A thread that finds the queue's lock held for long, or the queue full, writes
its line itself. NX2NAVI_LOG_ASYNC=0 writes every line where it is logged, as before.

Every write is timed in both modes: a "log: ... writes of the log took over 2 ms" line counts the slow ones, gives
the longest and says whose time they took.

tests/logq_check.py checks order and completeness with eight threads, a slow destination, a stopped destination with
the queue full, and the synchronous flush.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 07:22:41 -05:00
chrg6
00f06e669e Fix nine runtime faults a tester's report named: a stack overrun on mesh draws, lost YMM halves on preemption, and seven more.
- The mesh stage's storage table starts 0x90 bytes before the end of the 2 KiB constant bank, and all sixteen slots
  were written: 0x6C bytes past a stack buffer on every mesh draw. It holds the nine slots that fit.
- The prebuilt runtime is compiled without AVX, so its preemption stub saved only XMM0-15 while the lifted code of
  an AVX2 processor keeps data in the upper YMM halves, which the stub's callees may clear. Both bodies are built
  now and the one for the processor is chosen at the start.
- After an FSR 3.1 frame generation error the window's swapchain was still AMD's object but went to the plain Vulkan
  calls. Swapchain calls follow whose swapchain it is, and a failure takes it down as a switch in the menu does.
- A movie is paced at its own 60 fps under a lower limit too: at 40 fps it ended 25 s behind its sound.
- A lost device left the wait for an upload spinning for good; it ends the run with the device's report.
- The compute pipeline key had four bytes of padding that were hashed and compared, so a prebuilt pipeline could be
  missed and built again.
- A thread's TLS pointer was published before the TLS was allocated, so the slot was always 0.
- A fatal exit puts RivaTuner Statistics Server's profile back, and an ini saved as UTF-16 is read.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 21:54:40 -05:00
Tyler
2270664be2 Total each allocation once in the commit census, add a size breakdown, and write the profile on a crash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtayeqEG2x5woYzWuEQBGV
2026-11-09 08:20:01 -05:00
Valiantsin Dzerakh
0478c13e45 Import the base tree (MIT, see LICENSE) 2026-11-07 05:04:38 -05:00