110 Commits

Author SHA1 Message Date
chrg6
5c9e480f90 Swing the shake key's stick by the game's updates instead of the clock, two updates a side at any frame rate.
shake_stick picked the side from the time since the press, 67 ms a side (two frames at the game's 30 fps), so above
30 fps each side lasted more of the game's updates: 8 at 120 fps and 16 at 240, slower swings than the game reads at
its own rate. The side now comes from the game's present count, which frame_rate.c already kept for the flight
recorder and now gives through frame_step_presents, two presents a side. The press still swings for 800 ms of the
clock and a hold keeps it going, as before; while no present comes (a load) the stick keeps its side.

Not reproduced before the change (read from the code). The new tests/shake_key_check.py builds shake_stick from
input.c and passes (it fails 118 times on the old code); the runtime compile check and the -Wall comparison of
input.c and frame_rate.c show no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
74bedbee73 Name the upscalers' wait mark "upscaler GPU waits", since it also times the timeline wait after a refused creation.
The review of 9071dfc3 found that PERF_M_UPWAIT, printed as "upscaler device waits", times two waits: vk_dlaa.c
vk_upscaler_device_idle's wait for the device, and vk_upscaler_gpu_wait's host wait on the timeline after a refused
DLSS or FSR creation, which touches no queue. host.h tied the mark to the first alone, and a play-test that expects no
"upscaler device waits" would have read the second, at a refused creation on a card short of video memory, as a wait
for the device. perf.c now names the mark "upscaler GPU waits", and host.h, vk_ffx.h and vk_dlaa.c say that both waits
are in it. What makes it a wait for the device still shows in the log: the rollback switch in the "cfg: ... sets:"
line, a full list ("a wait for the device") and a failed timeline wait ("waiting for the device instead") in lines of
their own.

Checks that passed: perf.c, vk_dlaa.c and vk_ffx.cpp compile with -Wall -Wextra and no warning, upscaler_idle_check (one
name per mark kind), tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
c6b29834eb Give the upscalers' device-idle waits and their releases perf marks of their own, so a slow frame's line names them.
The twelve places where DLSS (vk_dlaa.c) and FSR 3.1 and FSR 4 (vk_ffx.cpp, vk_ffx4.cpp context_for) wait for the
whole device to be idle before they free what the GPU may still use go through one function, vk_upscaler_device_idle,
which NX2NAVI_TRACE=perf times as "upscaler device waits". The releases of DLSS features and of FSR contexts are timed
as "upscaler releases". These waits run on the replaying thread with the queue lock held, so the present's flip, the
completion thread's hold release and upload submits wait with them at each pause-screen size, reload, window size or
mode change; until now only the "DLSS feature creates" mark saw one of them, the wait in release_kept, which it summed
with the create. That mark now times the create alone.

Diagnostics only: nothing waits or frees differently. stutter_map_check, stutter_hitch_check, frame_step_check and
fg_reset_check passed; every runtime file compiles, the changed ones with -Wall -Wextra and no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
2e077edc9d Fetch the lazy write-back's ranges without the freeze lock, so a touch of GPU-written memory holds up only the threads that touch that range, with wb_lazy_unlocked = 0 to go back.
A CPU touch of an armed range ran its fetch (vk_memory.c lazy_fetch: a GPU copy after everything handed over, and a wait of up to 10 s) inside the fault handler under frz_lock. Every other fault, thaw, freeze and arm waited for the whole copy meanwhile: the replay's copies of guest memory (mem_copy_readable, vk_memory.c mirror_capture) and its freeze pass, the completion thread's arms, file reads and pool finalizes. A write that lost the race to the fetch went to thaw_fault after it and released every other armed range of its 2 MiB region, a second fetch. mirror_evict and hot_check fetched their victims' ranges under the exclusive pool_lock, which the completion thread's arms and every pool lookup wait for. This is AUDIT F-4-05 (C-12); the design's probe of the unchanged code had an unrelated mem_copy_readable, arm and freeze each wait 299 ms behind a 300 ms fetch, and the losing write 378.8 ms with two fetches.

A range now leaves the armed list into a table of ranges in flight (memory.c "Ranges in flight", lz_fly) in the same lz_lock hold, and stays there, its pages inaccessible, until its bytes are down and its pages read-write again (lazy_fly_run), without frz_lock. A fault on a range in flight waits for that entry alone on lz_lock's condition and runs the access again, the losing write included; a thaw, a mapping change, a release of its buffer, an arm, a freeze and mem_lazy_touches see it. Every store to lzr is made under lz_lock from the list, the entries and the region an arm is protecting (lz_arm_r, which replaces the previous commit's restore). An entry starts under frz_lock and lz_lock and ends under lz_lock alone, so a holder of frz_lock may wait for one: its fetch takes only the queue lock, which no frz_lock taker holds. mem_thaw releases what is armed in its regions before it takes frz_lock (lazy_release_regions), so file reads and pool finalizes fetch without it; a mapping change's thaw still fetches under it, as its page calls follow. The holds a fault sets (lz_hot, lz_hots) are now set as the range goes into flight, so an arm meanwhile finds them. The fetch takes several copies in one submission and one wait (MemLazyCopy), and the back end has four slots of command pool, command buffer and fence, so fetches of different ranges run at once; a release takes up to 16 armed ranges out together.

mirror_evict and hot_check now fetch what only their victims' mirrors hold before taking pool_lock exclusively, and then, under the lock, release only what was armed since (usually nothing) before the mirror leaves the list and the device address table names the imported buffer again. The design moved the release after the lock instead, which would have had the table change before the fetch: a shader of a submission still running reads through the table as it changes, and the fetch waits for every submission handed over. A victim that vk_pool_fini took off the list meanwhile is left to it; hot_check copies its log line's fields under the lock, as its records may be freed once the lock drops.

The new setting wb_lazy_unlocked (NX2NAVI_WB_LAZY_UNLOCKED, default 1, restart) set to 0 keeps frz_lock from the take-out to the end of each flight and leaves mem_thaw's releases to the thaw, as in 0.10.1; the table, the slots and the eviction order stay. The "mem: lazy write-back" line counts segments fetched; a second line gives the touches that waited for a range in flight, the longest such wait and the most ranges in flight at once; "mem: sizes" counts fetches that found every slot busy. The frz_lock and lz_lock comments say what holds what now (B-12). mem_lazy_touches has callers outside memory.c (camera_speed.c, nvn_api_cmd.c ctl_fingerprint), which now skip a range in flight instead of faulting on it and waiting.

Not reproduced in the game before the change. tests\lazy_fetch_check.py now runs 20 scenarios as their own processes: arm races, no stall of freezes, copies, arms, thaws and releases of other ranges during a fetch, joins with frz_lock held elsewhere, a losing write that keeps the region's other range armed, two fetches at once, a thaw, a mapping change and a release meeting a range in flight, an arm refused over one, the foreign give-back, the chunk mode, the old wait with wb_lazy_unlocked = 0 (joins and losing writes as well), the report lines, 80 touching threads past the 64 entries, and a freeze refused; all pass, in six runs. Taking the join before frz_lock out fails the join scenario. cfg_rows_check reports 0 problems; every runtime file compiles, and memory.c and vk_memory.c add no -Wall -Wextra warning; the test matrix passes 75 of 77, installer_test and worktree_test failing for the environment as before. vk_memory.c's slots and the eviction's order have no check without a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
2bf32649af Name the game's frame-rate ceiling as a period of its own, frame_step_ceiling_ns, for a pacer that hands the limit over.
When Reflex holds the frame limit with DLSS Frame Generation, the game's pacer is to keep only the rate the game's
code survives (240 a second; the quick menu read address 0 at 640 to 700), so that a limiter that lets go, or one
that counts other frames than the game's, cannot run the game past it. frame_step_ceiling_ns gives that period
from LIMIT_FREE_MAX_HZ, or LIMIT_MAX_HZ for a build with the fps60 or realtime patch set; frame_step_cap_ns is left
as it is. tests/frame_step_check.c checks 4166666 ns for a game stepping by the clock; frame_step_check passes.

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
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
cd3f015732 Log the guest's ExitProcess line after the exit guard, and bring the comments on gfx_window_closed's return up to date.
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>
2026-11-13 18:43:24 -05:00
chrg6
b23acff301 Leave a line in the log when a module ends the process with ExitProcess, and write the gathered lines at a focus loss.
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>
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
b4556957c2 Add a hang watch that logs a report when no frame is shown with the window in front, or the GPU does not finish, for 20 s.
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>
2026-11-13 18:42:34 -05:00
chrg6
bbea3ce2a0 Write the end of the run to the log file past its 64 MiB cap, up to a 4 MiB reserve counted on its own.
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>
2026-11-13 18:42:10 -05:00
chrg6
e39f71df39 Run every orderly exit through one sequence that puts RivaTuner's profile back before the slow pipeline save.
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>
2026-11-13 18:42:10 -05:00
chrg6
66d38dc0ac Put the kern_handle_close comment in host.h back in its column.
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>
2026-11-13 18:41:46 -05:00
chrg6
d8c046acc4 Destroy a movie decoder's system events and free their table entries when the decoder goes.
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>
2026-11-13 18:41:46 -05:00
chrg6
0672655c96 Let a destroyed vsync event give its slot when the vsync list is full, instead of ending the process on the 17th.
gfx_vsync_bind kept every SystemEventType address the game ever passed to GetDisplayVsyncEvent, never dropped one, and
called rt_fatal on the 17th distinct address. A destroyed event also stayed on the list the vsync thread signals 60
times a second. kernel.c now exports kern_system_event_live (mapped guest memory with a nonzero state byte), and with
the list full gfx_vsync_bind gives the first slot whose event is no longer live to the new one, which also takes the
destroyed event off the vsync thread's list; it stays fatal only when all 16 are live. Each new listing logs
"gfx: vsync event ... bound in slot N, M listed", so a long session's log shows how often new addresses come.

Not reproduced before the change: the fresh 0.10.0 session's system event count stayed at 9-10 across its area loads,
and no log here covers returns to the title. tests/vsync_bind_check.py cuts gfx_vsync_bind from gfx_present.c with a
stub liveness check: a 17th event takes the first destroyed slot, an 18th the next, and with 16 live events the 17th
still ends with the fatal. system_event_check now also checks kern_system_event_live. The compile check of every
runtime file passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
63228e7ca8 Hold a reference on an IPC session for the length of a request, and copy the transfer memory under the kernel lock.
SendSyncRequest looked the session up with kern_handle_get, which holds the kernel lock only for the lookup, and then
dispatched to the handler with no reference. A CloseHandle of the session's last handle on another thread meanwhile
reached ipc_handle_closed, which ran the close callback and freed the session while the handler still used it; for
the audio renderer, renderer_close joins the render thread and frees the renderer a RequestUpdate is reading.
OpenAudioRenderer read the transfer memory the same way, and a CloseHandle frees that object at once.

kernel.c gains kern_handle_hold, which runs a hold callback under the kernel lock after the lookup (handle_close
clears the slot under that lock before it calls ipc_handle_closed, so a close either comes first and the lookup
fails, or comes after and finds the extra reference), and kern_handle_copy, which copies an object under the lock.
ipc_svc takes the session with kern_handle_hold and drops the request's reference through ipc_handle_closed after the
answer, so the close callback runs on whichever thread drops the last reference; the request thread holds no service
lock by then. audren.c copies the transfer memory into a local IpcTransferMemory. Domain sub-objects are not freed
yet (IpcSession.objects), so their lookups are left for the domain object change.

Not reproduced before the change in a game run (no log shows a renderer closed during play). tests/ipc_session_check.py
builds ipc.c with kernel.c linked: a request whose handler blocks while another thread closes the session's only handle
must leave the close callback until the handler returns, and a closed transfer memory handle must copy nothing.
Against the previous ipc.c it fails with the close callback run while the handler was blocked. system_event_check,
sync_wake_check and the compile check of every runtime file pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
0ffac80222 Name the queue lock's long holders under NX2NAVI_LOCK_TIME, and time each swapchain rebuild's hold of it.
With lock_time = 1 the 0.10.0 session showed flip_locked waiting up to 48.61 ms for the queue lock, and the replay's
queue_submit up to 8.05 ms. Nothing named who held it: only vk_texture.c's lock records its holders. A swapchain
rebuild holds the lock across vkDeviceWaitIdle and the create, and that hold was not timed either.

vk_queue_lock, vk_queue_lock_raw and vk_queue_trylock_raw are no longer inlined. Each takes the lock with
plat_rmutex_lock_at and its own return address, so the wait report keeps naming the caller (before, the inlined call
named a site inside flip_locked). At the outermost take, with lock_time on, the holder's site and the time are noted.
vk_queue_unlock passes a hold of 5 ms or more to perf.c perf_qlock_hold. The frame-time summary then prints "queue lock
held 5 ms or more" beside the texture lock's holds, by site, as the waits are. create_swapchain logs how long a
successful rebuild took: the wait for the lock, the hold, and the part spent waiting for the device to idle. It logs
the first 16 rebuilds and each doubling of their count.

Diagnostics only: the counters are behind NX2NAVI_LOCK_TIME, which is off by default, plus one capped line per
rebuild. Checks that passed: pass_cc_check on every runtime file (host.h changed), no new -Wall -Wextra warnings in
gfx_present.c and perf.c, stutter_hitch_check, pgo_build_test, sync_hw_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
74b9687a8f Time VMA's device memory allocations in the trace's work marks.
An area's first visit (0.10.0 fresh session, nx2navi.1.log, frame 10113) took 421 ms, and the stutter line left 192 ms
of it as replay and 27.5 ms unattributed after the pipeline, translation and texture waits. The same frame made 78
device memory allocations of 4442.7 MiB, but the "device memory allocations" mark carried only a count and bytes:
vk_headroom.c on_allocate added it with zero time, since VMA's allocate callback runs after the allocation. At the
8-19 ms per 256 MiB block that vk_headroom.c measured, those allocations could be most of the frame's unexplained
time.

vk_device.c create_device now hands VMA a VmaVulkanFunctions whose only member is vkAllocateMemory = timed_alloc;
VMA takes the others from its static table (vk_vma.cpp VMA_STATIC_VULKAN_FUNCTIONS 1; its ImportVulkanFunctions
copies only the non-null members). timed_alloc brackets vkAllocateMemory with perf_mark_begin and perf_mark_end, which
cost one branch while the trace is off. on_allocate's zero-time mark is gone, and perf.c mark_text prints the
allocations' time, count, bytes and longest like the other kinds. The texture statistics' count of large allocations
is left as it is (adding the time there would have counted every allocation a second time).

Diagnostics only, no change to play. Not reproduced before the change (it needs an area load with trace=perf).
stutter_map_check, stutter_hitch_check, frame_step_check and mem_growth_check pass; every runtime file compiles with
the dev flags after the host.h comment change, and vk_device.c, vk_headroom.c and perf.c add no -Wall -Wextra
warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
af8e2a323b Charge the frames a memory-census walk overlapped to census, the memory report's time to a report part, and walk once at frame 3600N.
With mem_census = 1 and trace = perf, vk_memory.c census_main walks the address space for 100-240 ms on its own
thread, holding the process's address-space lock. The replaying thread's write-watch queries wait for that lock
("mem: slow write-watch query: 20-34 ms ... 0 pages written"), and ft_frame charged the frame to "replay": in the
0.10.0 fresh session (nx2navi.1.log, 7,974,927 bytes) 63 of the stutter map's 66 replay hitches sat beside such a
walk, 49 of them at frames 600N+1..3. The presenting thread's own 600-frame memory report (10-17 ms, timed in
vk_mem_census since b81a9561) went to "unattributed". And at frame 3600N, census_check walked on the presenting thread
and the same frame's heap_log started census_main's second walk.

- perf.c perf_census_walk brackets census_main's walk (atomics); ft_frame moves the part of a frame over 25 ms (or a
  stutter-map candidate) that overlapped the walk out of the replay part first, then out of the unattributed part,
  into "census". The rule is runtime/perf_census.h census_charge, capped by the overlap and the frame.
- A new cause "report": vk_mem_census feeds the report's time (perf_report_part), and so do perf.c's memory
  snapshots and its 600-frame sizes lines; the next frame takes it, so the overlap rule no longer has to infer it.
  The cause list changes together: perf.c FT_C_* and ft_cause_name, stutter_map.c SM_CAUSES and cause_short,
  tests/stutter_hitch_check.c names[].
- census_check records the frame it walked (perf_census_walked_frame); heap_log does not start census_main for that
  frame, so frame 3600N walks once. A frame stamp, not a time window, since prof_commit_census can fail and leave
  perf_note_wc uncalled.
- The "mem: process" line gives the walk's start and end on the run's clock and its length, so a play-test with
  mem_census = 0 can tell the walk from the presenting thread's report as the cause of the 600N+1 hitches.

New tests/census_charge_check.py builds runtime/perf_census.h alone: no overlap, partial, contained and still-running
walks, the charge from replay first, capped by the overlap and the parts, and 100,000 random frames in which the census
part never passes the frame; docs/building.md describes it. Diagnostics only: NX2NAVI_MEM_CENSUS defaults to 0, and
nothing in gameplay, pacing or the save changes. Not reproduced before the change (it needs a game run). The pass's
known-issues table, which should say that hitch counts are taken with mem_census = 0 and trace off, is not in the
repository. census_charge_check, stutter_hitch_check and stutter_map_check pass; every runtime file compiles with the
dev flags, and perf.c, stutter_map.c and vk_memory.c add no -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
0472734008 Time the hand-off to the present thread, the present call and other threads' acquire waits, and count the acquire's GPU wait once.
The perf lines could not see pacing. In the 0.10.0 fresh session (nx2navi.1.log, 7,974,927 bytes) all 666 windows read
"pacing 0%" and "bound: cpu" and all 70 slow-frame lines "pacing 0.0 drain 0.0", while 610 windows had a 16.x ms
interval at fps_limit 60 under the game's own limiter. The pacer's wait happened where perf does not count it:
vk_backend.c present_image's wait for the present thread to take the previous picture was untimed, perf_end drops
waits on any thread but the presenting and replay ones (a game may acquire its window image on another), and the
acquire's GPU wait was counted twice, as pacing around b->fence_wait and as a fence wait inside vk_sync_fence_wait
(F-7-04).

- host.h: PERF_HANDOFF, the presenting thread's wait to hand the present thread the next picture, timed in
  present_image from under pl (where pending is read) and kept in PresentJob.handoff_ns. perf_end_any counts a wait
  on any other guest thread into a sum that perf_present adds to the frame before ft_frame reads its parts.
- nvn_api_obj.c nvnWindowAcquireTexture: the pacer's sleep is timed with perf_end_any; the GPU wait counts only as
  the fence wait it is; one line names the threads that acquire and present once the first present is known.
- vk_backend.c present_main: the TRACE=present line adds the present call's time (vkQueuePresentKHR, under the queue
  lock, where an overlay's frame limiter sleeps) and the job's hand-off wait; with trace=perf, a line per 60 presents
  gives the present thread's blit and submit, paced wait, present call (avg, max), time past target and the hand-off.
- perf.c: "handoff" in the window line's shares and the slow-frame line, "hand-off" in the worst-frame lines and as a
  cause of its own for frames over 25 ms and the stutter map. The "bound:" rule stays as it is until play-test numbers
  exist.
- The cause list changes together: perf.c FT_C_* and ft_cause_name, stutter_map.c SM_CAUSES and cause_short,
  tests/stutter_hitch_check.c names[] (now sized from SM_CAUSES), and tools/stutter_map.py, whose cause and hitch
  patterns now take a hyphen. flight.c's frames.tsv gets a handoff_ms column (blocked_names checked against PERF_N),
  and tools/flight_view.py lists it.
- tools/perf_summary.py reads the hand-off share, the present-thread lines and the TRACE=present lines, and prints
  percentiles of the present interval, paced wait, time past target, present call and hand-off.

New tests/perf_summary_check.py makes lines with the runtime's format strings (checked against the C sources) and
checks the percentiles, the window averages and the hand-off share, and that older lines without these fields still
parse; tests/stutter_map_check.py gets a hand-off hitch and area cause, which the old patterns dropped. Diagnostics
only: no wait, pacing or presentation change. Not reproduced before the change (it needs a game run with trace=perf).
perf_summary_check, stutter_map_check, stutter_hitch_check, flight_flash_check and frame_step_check pass; every
runtime file compiles with the dev flags after the host.h change, and the changed files add no -Wall -Wextra warnings.

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
7b0dbeb18d Write the session's reports when the window loses the focus in play, more often in play, and log every end of the game in the launcher.
The session of 2026-11-13 ended 8 s after its window lost the focus, after 7 minutes of play, with none of the game's exit lines: no frame-time summary of its last period, no stutter map of its last minute and no growth ranking at all, as the ranking came every 10 minutes. When the window loses the focus in play, the present thread now writes the frame-time summary so far (starting a new period), the stutter map and the growth ranking at its next present, at most once a minute; formatting only, nothing asks the device. The growth ranking comes every 2 minutes of play instead of 10, the stutter map every 5 instead of 10.

A process that ends through exit() rather than plat_exit now writes the log thread's queue last, after the frame-time summary's exit handler, instead of losing it.

The launcher logged a game's end only for a nonzero code, so the session's end with code 0 left no line anywhere. It now logs every end of a game it started, and when the game's log has no line of the game's own end (the window's close, the game's ExitProcess, a timed run's end, a FATAL or exit line), it notes the end there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
0e0cfe0827 Keep the freezes of idle regions back after slow ones and around area loads.
After a slow freeze the passes wait 250 ms, doubled for each further slow one, but the first fast freeze after a hold started the doubling over, so in the seconds after a load a 4-5 ms freeze came every 250 ms (Kakariko, 2026-11-13: five in a row, 16 frames apart), and freezes of 4-10 ms pushed frames of 25-52 ms over the stutter line. The doubling now starts over only after freeze_quiet_ms (default 5000; 0: the old rule) without a slow freeze. No pass runs while the texture pool makes an area's textures, when the frames are held anyway and one freeze took 32.4 ms, nor for freeze_load_hold_ms (default 3000; 0: off) after that or after an area's load. The regions that go idle meanwhile stay queued and are frozen later; their write-watch queries go on until then. A region is now queued once instead of again every freeze_idle submissions while the passes are held.

The rules are in runtime/freeze_hold.h, checked by tests/freeze_hold_check.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
f7487ceee5 Make the memory census walk once and fire on the process's own commit, not on its private bytes.
With mem_census = 1 the census trigger compared the private bytes with the last census, and Windows charges the video memory to them, so every gigabyte of textures started another 130-260 ms walk on the presenting thread: 22 of the 33 walks of the 2026-11-13 session came from that trigger, while the process's own commit grew 307 MiB in play. The trigger now reads the private bytes less the write-combined memory of the last walk (and what the driver's device-local figure grew since), compares them with the highest figure seen at a census, and waits 60 s after the last walk; the 3600-frame censuses stay.

On Windows one walk of the address space now gives both the commit census and the guest regions' committed bytes, which mem_guest_census walked a second time. The census counts the driver's write-combined views apart instead of as guest memory, counts as guest only the reserved guest regions and the loaded modules, and ranks the process's own allocations wherever they lie. The frame after a walk charges its time to a new "census" cause in the frame-time summary and the stutter map instead of "unattributed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:51 -05:00
chrg6
e682934bd0 Write the log file before stderr, and keep a blocked console from stopping the log and the exit.
A selection in the console window of a game started directly held back the write to stderr, and with it the log
file, the exit hook and the crash report. The log file now comes first, stderr goes past the C runtime's stream lock,
and the lines of an exit, a fatal error or a crash skip stderr while another thread is stuck in it. A game run turns
the console's QuickEdit mode off, and a closed console, Ctrl+C or Ctrl+Break ends the game the way a closed window
does, with the settings and saves written and the exit logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:06:27 -05:00
chrg6
328554e7c6 Run the sound manager's job at 60 Hz with whole steps above 60 fps.
The job (main+0x415300) hands the time manager's step to the sound system in its phase 0 and runs the sound objects'
and the system's updates in phases 1 and 2. With the set ostep that step is a fraction of a frame above 60 fps, and fed
fractions the game has been reported to keep sounds held after an area unloaded until no voice was free, new sounds
and then the music going silent until a restart. The new set osound (sound_steps, on by default) calls the page's
+0xf10 after the job's prologue: phase 0 adds the step to what the job is owed and runs once a whole step has built
up, phases 1 and 2 follow that choice, and a job that does not run gets phase 3, which returns through its own
epilogue. After phase 0's load of the step the page's +0xf00 makes a fraction one whole step. At 60 fps and below
the job runs every frame with the game's step. Both sites were checked against the module; frame_step_check covers
the gate at 60, 30, 144 and 240 fps. Needs the game code generated again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 05:54:58 -05:00
chrg6
da65aa6099 Bring public main's Steam co-op, the removal of the old co-op and the launcher's Steam library file into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:37:01 -05:00
chrg6
750034b7fd Run the water and floor surface simulations at their own 60 steps a second above 60 fps on 1.1.1.
The physics' two water managers and the game's interactive floor simulation (the Deku Tree's ground webs) step their
surfaces in fixed steps of 1/60 s, as many whole steps as the frame's time holds, and drop the fraction, so every
frame shorter than 1/60 s ran none and the surfaces stood still above 60 fps. The new set owater (water_steps, on by
default) calls the page between the division and the game's cut, which keeps the fraction per object; at 60 fps the
count is the game's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:42:40 -05:00
epicmeme546
c84c035939 Remove the co-op of 0.8.0: its runtime layer, relay, menu tab, settings, tests and docs.
The hidden beta shared story progress through a relay a player had to run and never showed the other players. A new co-op replaces it. Gone are runtime/coop*.c and their headers, the menu's Co-op tab and its texts in four languages, tools/coop_relay.py, the co-op tests, docs/coop.md, the [coop] section of nx2navi.ini.example, the --probe-coop mode, the coop trace item, and Monocypher, which only this layer used.

The old settings stay known as retired keys, so an nx2navi.ini that still sets them logs no unknown key, and they stay on the list of private settings, so a bug report keeps a room's secret, a relay's address and a name out. The platform layer's UDP sockets and fs_save_snapshot stay for the new co-op; cfg_ini_now and fs_profile_path, which nothing else called, are removed.

Checked: the game builds and starts, the menu shows its four tabs, --probe-cfg and --probe-fs pass, and the launcher's private-settings test passes. tests/save_share_check.py builds without its co-op stub; it cannot run from this worktree because the folder's path is longer than Windows allows, on main too.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 03:37:26 -05:00
chrg6
c6f2484ef9 Add Native (100%) to the Upscaling row: the scene drawn at the picture's own size with no upscaler.
The row writes scene_share = 1 with dlaa = 0, which the start already sized the render-target heap for; widescreen.c now
reads the share live, says when the heap needs a restart for it (the row's note and the Restart count), and logs the
scene size, the picture size and how the scene is scaled once the fixed share takes over and at each change. The
stand-in copies a scene of the picture's size texel for texel with a nearest sampler, and the performance overlay shows
Ocarina's real scene size instead of the other builds' placeholders. scene_share gets its settings row and its ini
text; the row's texts are in all four menu languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 00:43:03 -05:00
chrg6
2820781fb7 Log the memory by owner every 3600 frames, and how often released textures come back.
Every VMA allocation is counted under the file that made it until it is freed, so the new "mem: by owner" line
splits the device-local memory the driver reports into textures (live, GPU-drawn, idle, released, released too
lately to drop), pools and VRAM mirrors, upscalers, replay buffers, uniform snapshots, the device address table,
the ASTC decoder, present and post-processing, headroom spares, the free space inside VMA's blocks and what VMA
never allocated. "mem: host" gives the host-visible allocations, the process's commit and working set and the
game's heap. "vk: released textures" counts take-backs by how long the texture was released and textures made
again after a drop by a bound or a pressure, with the drop's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 23:21:17 -05:00
chrg6
f2a5800c1e Merge the stability pass's fixes for the points its review found.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

# Conflicts:
#	runtime/cfg.c
2026-11-12 22:32:48 -05:00
chrg6
4a6ba66115 Tell a start that was closed or stopped during the boot's builds from a driver crash, and limit the whole warm.
A marker or a journal left by a start that died in the boot's builds was always read as a driver crash. Since the
whole warm those builds run for minutes, so a start ended from outside in that time (the task manager, a program that
stops the game, the power) switched the pipeline prebuild off for that driver for good, or put up to 32 variants on
the skip list that the driver had merely been building. Closing the window was already an orderly end and still is.

The crash handler now leaves a note while the boot's builds run. With the note the next start does what it did: it
skips the prebuild, or the variants the journal names. Without it the end is taken for a stopped start: the pipelines
are built again and the journal's variants are blamed on nothing. Three such ends in a row still skip the prebuild,
for a driver that ends the process without a fault the handler sees. The boot's journal has a file of its own, so the
preparation's keeps its meaning.

The whole warm waits prewarm_full_s seconds at most (600): when they are up the game starts, the builders step down
to the background's thread counts and priority, and the file says "done", so the boot screen has one turn per driver.

tests\boot_warm_check.py covers what a leftover marker means with and without the note.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:24:22 -05:00
chrg6
a8639f1cb6 Keep the scene and the picture inside the shipped render-target heap when the grown heap table is not served.
The scene's share followed the settings alone. When the grown table could not be built or written (a table in a form
that is not rewritten, as a mod's may be, or a profile folder that cannot be written), the game kept the shipped heap
and the scene still went to the whole picture inside it, which is the crash the growth was made for.

The start now asks for the table before the first present and looks at the answer: without the grown table the scene
is three quarters of the picture at most and the picture stays the game's own 2560x1440, with a log line that says so.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:09:37 -05:00
chrg6
7ff1a21ef2 Grow the render-target heap for the scene's share only where the start's mode needs it, and never past the pool.
The heap grew by the full-picture factor whenever the upscaler setting was on, in every mode, and without a limit:
915 MiB at 3840x2160 where the modes below three quarters need 382, and at 10240x2880 an application pool of 13.5 GiB,
past the 12 GiB heap region, where the game's SetHeapSize is refused and nothing starts.

The share is now the start's own mode: all of the picture for dlaa, the game's own most for the modes below it. The
growth stops at what the pool can take (2,478 MiB): the scene's share is lowered to what fits, with a log line, and a
picture too large even for the game's own scene gets the most there is and a warning. A mode chosen in the menu that
renders more than the start's keeps the start's share until a restart, as the log and nx2navi.ini.example now say.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:08:44 -05:00
chrg6
6d049c244f Keep the water's ripples moving above 60 fps by carrying the step's fraction for each water object.
The water renderer runs trunc(step) times the water's substeps of its ripple simulation a frame, which is none while the step is under one frame: the rings stood still above 60 fps. A new site of the set ostep hands the cut to frame_rate_water_host, which adds the step to a remainder kept for the water object and returns the whole steps in it. The waters have a table of 64 slots of their own, as fewer slots than surfaces would have them take each other's in turn.

frame_step_check.py calls the water and day hooks as their sites do. It also gets the three platform stubs it has lacked since the day clock's hook, without which it did not link.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:13:19 -05:00
chrg6
a464ffafbc Merge Tobim6's work for older graphics cards: mesh shaders emulated with compute, video memory kept within the heaps' budgets, staged mirror uploads and the submit wait that goes with them.
Conflicts: the settings key lists take both sides; frame generation keeps the camera-cut handling and logs its counters every 600 frames with the cuts among them; the pass-time mark moves into draw_viewport; mo_desc keeps the pose depth and notes the reader.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:14:58 -05:00
chrg6
65dacfa7c7 Ask the write watch once per run of regions in the texture pool's check, and pass released entries by.
At every submission that binds the texture pools the replay checks each listed texture for CPU writes, and takes the write-watch snapshot of the 2 MiB regions that have none yet as the list comes to them: one query a region, since the list is in no address order. Standing still that is 5 to 10 queries and the check takes 0.11 ms. While the player runs through Kakariko or Castle Town the game writes about 32 textures into new storage every four or five frames, a region the CPU wrote is not frozen again for 1,800 submissions, and the check asked for 100 to 500 regions one by one, 2.7 times a frame: 0.5 to 1.5 ms a check, about 3 us a query, in five sessions of one player. The regions are now collected for the whole list first and asked in one query per run of adjacent regions. On this machine 400 queries of 2 MiB take 727 us and one query over the same 800 MiB takes 360 us. The same regions are queried and the same pages stamped, all of them before the list is looked at, as each entry's were before its own look; a run across the edge of a watched reservation is left to the queries by range. pool_presnap_runs = 0 asks region by region as before.

A released entry stays in the list until it is destroyed, the check does nothing for it, and the stamp it compares stays as it was at the release, so one whose region took a write later missed the region check at every submission from then on and was fetched only to be passed by: 1,000 to 6,300 entries a check, with up to 8,192 released entries kept. The list now carries the released state, set at the release and cleared at the take-back, and the check passes such an entry by without touching it or asking for its regions.

The pool log's freshness line counts the released entries passed by, and its snapshot queries count the queries either way. --probe-vkmem has a case for the queries by run.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:49:54 -05:00
Tobim6
ad288e3bc1 Staged mirror uploads: the replay copies guest bytes for the VRAM mirrors while it records, and the first-submission wait ends once the replay has submitted; 8 texture pool sets.
On 0.9.1 this is the release's own mirror capture (NX2NAVI_MIRROR_CAPTURE_KB) turned on: with
NX2NAVI_MIRROR_STAGE=1 (the default) the pages the CPU wrote are copied out of guest memory, up to 64 MiB a
part, when the part is handed to the queue. A submission whose prologue copied everything that way reads no
guest memory late, so the game's wait after a frame's first submission ends once the replay has submitted it
(vk_sync_recorded_wait). One with a range left to the GPU-time copy (a pool with a write-back in flight or a
twin) or a draw that reads a pool without a mirror is noted (vk_mem_note_live), and its wait is the GPU's.
Also 8 texture pool sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:22:47 +01:00
chrg6
7dcfb72b70 Keep the time of day at the game's pace under the real-time step, its fraction carried from frame to frame.
The day's update (main+0x3cdf70) adds trunc(2 x frames per update) game seconds a frame. With the set ostep's step,
the display interval in 60 Hz frames, that sum lost its fraction every frame: 1 a frame from 61 to 119 fps, 0 above
120 (the clock stood still), and at 60 fps a step a little under one frame dropped a second now and then. Its convert
now calls the patch page's +0xf60 (runtime/frame_rate.c frame_rate_day_host), which keeps the fraction per counter and
hands out the whole seconds. NX2NAVI_TRACE=perf logs, every 10 s, the seconds given beside those the cut would have
given.

Measured in Kakariko, 10 s windows: at a 60 fps limit 998 game seconds against 881 from the cut alone; at 144 fps
1,201 against 219 to 972 (the steady 1,200 a 10 s window should have).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:54:21 -05:00
chrg6
0c343e04a4 Record the game's time step at every present in the flight recorder: the patch page's step, the time manager's and the game's present count.
frames.tsv gains step_frames, game_step_frames and guest_presents. The game writes its step at its own present, a frame or two before the replay presents that frame, so the present count is there to line the two up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 02:54:32 -05:00
chrg6
10dd325dbe Copy the pages the CPU wrote into a VRAM mirror when their part is handed to the queue, behind NX2NAVI_MIRROR_CAPTURE_KB.
The prologue of a part copies the pages the CPU wrote since a mirror's last sync from guest memory into the mirror when
the GPU runs it, a frame or more after the game's submit when the GPU is behind: the GPU then reads what the game
wrote there meanwhile, values meant for a later frame. With NX2NAVI_MIRROR_CAPTURE_KB=n the CPU copies up to n KiB of
those pages per part into a host buffer of the submission when the part is handed over, and the prologue copies them
from there. A pool keeps the GPU-time copy while a write-back of its mirror into guest memory is still on the GPU (its
fence, now kept per pool), when it has a twin (the device address table's writes reach guest memory unrecorded), and
for a range with a page that cannot be read without a fault (memory.c mem_copy_readable, under the freeze lock, so a
lazily armed range stays with the GPU). With the default of every mirror twinned (NX2NAVI_GPA_TWIN_MAX_KB=0) it
copies nothing; it is a test switch for the replay's late reads, default 0.

Measured on an RTX 5090 with NX2NAVI_GPA_TWIN_MAX_KB=64 (mirrors of pools over 64 KiB without a twin), 75 s runs
with warm caches, NX2NAVI_MIRROR_CAPTURE_KB 0 against 8192: Kakariko 0.95 MiB a frame copied in 149 ranges for
0.21 ms of the replay thread, 116.7 against 118.4 fps (interval median 6.26 against 6.46 ms); Hyrule Field walk
1.38 MiB in 224 ranges, 0.37 ms, 110.2 against 107.2 fps, 1% low 83.3 against 78.1. 0.08 MiB a frame stayed with the
GPU-time copy. A first version that took the freeze lock and asked the system for each range's pages cost 2.9 to 4.3
ms a frame. A Kakariko run with NX2NAVI_REPLAY_ASYNC=1 and the copy on played through without errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:38:18 -05:00
chrg6
2cccdb8823 Keep the game on the largest-L3 die only when that die has 8 cores, and reserve no recorder core there.
cache_die moved the process to the logical processors of the largest L3 cache whenever the L3 sizes differed. That is
right for a two-die X3D desktop part with 8 cores a die, but it also held the game to the 4 large cores of a mobile
part whose 8 small cores share a smaller L3, and to the 6 cores of a 12-core X3D part, where the recording thread's
reserved core then left the game 5. The die is now used only when it has 8 or more physical cores (perf.c
cpu_placement logs when one is passed over). And while the process runs on that die alone, RECORDER_CORE=auto
reserves no core for the recording thread, since the game's threads already have the die to themselves; =1 still
reserves one.

Checked: all runtime files compile. This PC has a single-die processor (one L3), where neither rule applies: a run
still reserves the recording core (logical processors 14 and 15 of 16) and logs no cache die.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:38:18 -05:00
chrg6
d3e50328bf Keep movies with their sound at any frame rate: one movie frame per update, and the pacer keeps the movie's slots through a late frame.
The game's movie player adds the time manager's step to an accumulator at each update and advances the movie by whole
pictures of it, at most the player's limit per update, which is one for the Triforce movie. While a movie plays the
game is held at 60 updates a second, but every late frame cost an update, the pacer started its slots again from the
late one, and the steady step's payback of the lost time came as steps over one frame, which the player cannot turn
into more than one picture. So each late frame put the picture further behind the sound, which plays by the clock, and
it never caught up.

While a movie plays the step is now exactly one movie frame, and the pacer keeps the movie's slots through a lateness
of up to a second, presenting the frames after a late one at once until the game is back on them. The updates then
average the movie's rate, and the picture, the event's update count and the game's clock all stay with the sound.
movie_clock = 0 gives the earlier behaviour. The movie log now measures the picture on screen against real time where
the game releases it (the game fetches pictures ahead into a list of its own) and reports it at the end of each movie.
test_hitch = <every>:<ms> makes a frame that much late every so many presents, for test runs.

Measured on the Triforce movie (event Dm_Kokiri_0095, 7,040 pictures, 117 s), one exe with movie_clock = 0 against 1,
and a 70 ms late frame every two seconds (test_hitch = 120:70), as a run with natural hitches had shown a drift of 40 ms
every ten seconds. Drift of the picture on screen behind real time, from the movie's first 10 s to its end:

  fps_limit   before (updates/s, drift at the end)   after (updates/s, drift over the movie)
  unlimited   58.86, 2,187 ms behind                  60.22, flat at 53 to 55 ms
  144         58.86, 2,196 ms behind                  60.04, flat at -11 to -15 ms
  120         58.98, 2,257 ms behind                  60.20, flat at -1 to 0 ms
  60          58.88, 2,220 ms behind                  59.98, flat at -21 to -22 ms

Without the late frames both kept time (unlimited: before flat at 21 ms, after flat at -13 ms). The selftest,
--probe-cfg, --probe-kernel, --probe-movie and tests/frame_step_check.py pass; the check now plays a movie started at
240 fps with late and short frames and needs exactly one frame every update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:09:16 -05:00
chrg6
5d51933969 Arm a written-back range only while its mirror is still the pool's, and give back an armed range of another mirror instead of marking it stale.
A write-back finishing on the completion thread could arm its range between a mirror's eviction and the drop of its
buffer, naming the freed buffer; a later write into the new mirror then marked that range stale and its next touch
copied from the freed buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:09:15 -05:00
epicmeme546
760a7aaaf9 Hold a memory region thawed after a write thaws it soon after its freeze, so the game's streaming buffers stop going back and forth between frozen and thawed.
Regions the game has not written for a while are write-protected so that the per-submission write-watch queries can skip them, and the first write thaws them. The game's loaders keep writing the buffers they stream an area into, so those regions were frozen again as soon as they had been idle a moment and thawed again moments later: running from the Kakariko Village gate across Hyrule Field froze 31,004 regions and thawed 28,918 by a write in nine minutes. While the loaders wrote and the driver locked new memory, each thaw's VirtualQuery took 15 to 20 ms: a loader thread stalled 78 to 111 ms in thaws in every one of the hitches of 125 to 140 ms of that walk, with the game's main thread waiting for it.

A write that thaws a region within a minute of its freeze now holds it thawed for 4 seconds, doubled at each such thaw up to 10 minutes; a region that stayed frozen longer starts again from nothing. On the same walk: 8,284 freezes and 6,355 thaws, no frame over 100 ms while Link ran where 29 and 52 a minute had been, the slowest 1 % of frames at 19 and 24 fps instead of 9 and 7, and at the walk's end, Link standing at the edge of Lake Hylia, 60 fps instead of 38 with the slowest 1 % at 43 instead of 20. The perf trace's write-watch line counts the holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:06:16 -05:00
chrg6
31e0a02901 Merge the mesh shader fallback: task and mesh programs drawn through compute passes and an ordinary draw where the card has no mesh shaders.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 06:01:39 -05:00