Commit Graph

1131 Commits

Author SHA1 Message Date
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
68bc197393 Route present_image's fence-wait results through the lost-device check, cap their lines, and skip a capture whose wait failed.
present_image's 5 s wait for the slot's fence passed its result to nothing but a "present fence wait timed out" line.
A lost device was reported as a timeout, and since a lost device's wait returns at once, the line repeated at the
game's present rate until another thread noticed the loss. A GPU hung but not lost repeated the line every 5 s with no
cap. The result now goes to vk_device_lost_check first. The line says whether the wait timed out or failed, with its
code and count, for the first 8 times and at each doubling.

An acquire answering an error other than OUT_OF_DATE (VK_ERROR_SURFACE_LOST_KHR, say) skipped the frame without a
word. Each such code is now logged once. Making the surface again would be presentation and device work and is left
alone.

The capture path waited for the slot's fence and then read the read-back buffer whatever the wait returned:
save_capture and grab_deliver could copy a frame the GPU had not finished. A failed wait now goes to
vk_device_lost_check, marks no capture valid, and ends a pending grab with no pixels, as the no-buffer path does.

Not reproduced before the change: the fresh logs have no such line. Checks that passed: pass_cc_check on
gfx_present.c, no new -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
c0f8a3224d Stop the VRAM pressure check from walking every texture and every released entry again when nothing can go.
Under VRAM pressure or a standing mirror room request, sweep_pressure runs every eighth submission. evict_idle walked
the whole texture table and, when it found no idle candidate, returned before it set evict_wait, so the next check
walked it all again. The released-list loop before it started from the head each time and passed every stale entry it
could not drop (up to RELEASED_HARD) until PRESSURE_STEP drops.

evict_idle now keeps a wait of its own after an empty walk: no new walk for EVICT_EMPTY_WAIT (16) submissions. It is
a separate idle_empty_wait, because evict_disused, which sweep_pressure calls right after evict_idle when it freed too
little, stops while evict_wait is ahead, and sharing that wait would have stopped disuse eviction for as long as the
idle walk stayed empty. The released loop looks at up to EVICT_LOOK (1024) entries a check from a cursor (press_at)
that rel_unlink moves off a leaving entry, as for sweep_at and aged_at. It starts from the oldest again at the first
entry released less than STALE_FRAMES ago or at the list's end. The trace=perf eviction line counts the empty walks
and their time.

Not reproduced before the change: the 0.10.0 session had no VRAM pressure. Checks that passed: pass_cc_check on
vk_texture.c, no new -Wall -Wextra warnings, tex_release_check, tex_compact_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
28a69d23cc Write the GPU-drawn textures of a hard-count batch back in one upload batch before the drops.
Past RELEASED_HARD, sweep_hard drops up to HARD_BATCH released entries in one walk (2969005e). drop_tex writes a
gpu_dirty entry back for DROP_HARD when the fence write-back would take it, and each write_back_n(&e, 1) call ended
in vk_upload_flush(1), a GPU round trip. A batch with many such entries waited once per entry, under tex_lock, on the
thread that releases, the replay's overwrite_others included. The rule before dropped one entry per release, so it
waited at most once.

Now sweep_hard gathers the picked entries the same way drop_tex decides on them: an entry whose storage the CPU
rewrote since the GPU drew into it loses gpu_dirty, and one wb_wanted takes is written back. All of them go back in
one write_back_n call, one upload batch with one wait. write_back_n clears gpu_dirty, so drop_tex then finds nothing
to write back. The same entries go, in the same order, and the entries written back are counted in n_drop_wb as
before.

Not reproduced before the change: the 0.10.0 session never reached RELEASED_HARD, and no check without a device
covers write_back_n. Checks that passed: pass_cc_check on vk_texture.c, no new -Wall -Wextra warnings,
tex_release_check, tex_compact_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
4840f57490 Let the completion thread's collect give way while an upload holds the upload lock.
completion_main calls vk_mem_collect after it publishes each new done, and vk_mem_collect waited for cmd_lock. An
upload flush holds that lock across vk_queue_lock, behind a present call (FIFO, a frame limiter inside the present,
frame generation) or another long queue-lock hold. With wait = 1 it also holds it across its batches' fence waits,
and stg_create waits for batch fences under it. The single completion thread blocked there could not process the next
timeline value, so the next done, and every fence waiter for it, waited for that chain.

vk_mem_collect_try only tries cmd_lock. When the lock is busy it still destroys the finalized pools, which need only
retire_lock, and leaves the deferred objects to a later completion or to the next upload flush's own collect. After
eight busy tries in a row it waits as vk_mem_collect does, which keeps the deferred list bounded, as before. pool_fini
keeps the waiting vk_mem_collect. The trace=perf "mem: sizes" GPU line counts the collects that gave way.

Not reproduced before the change: the completion thread's lock waits are not timed. Checks that passed:
pass_cc_check on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_memory.c and vk_sync.c,
sync_hw_check, gpa_check, mem_budget_check, tex_release_check, mem_growth_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
e70cbb6a39 Destroy finalized pools in vk_mem_collect outside every lock, not in the completion callback or under the upload lock.
pool_retire destroyed the record at once (destroy_rec: vmaDestroyBuffer of the mirror, vkDestroyBuffer and
vkFreeMemory of the imported memory, which the driver unpins). It ran in two places, and both were costly. In the
completion thread's callbacks it ran before done is published, so every fence waiter waited for those frees: 717.7 ms
in 7,250 calls in the 0.10.0 session, up to 76.8 ms within one 240-submission window at an area change. From
pool_wait_collect it ran under cmd_lock, in the upload flush of whichever thread collected first, such as the replay's
per-submission vk_upload_flush or the game's presenting thread. Every upload and deferral waited on cmd_lock
meanwhile, and a slow frame showed the time as unattributed.

Now pool_retire only moves the record from the retiring list to a new pool_dead list (retire_lock). vk_mem_collect
alone drains that list, after its cmd_lock section and holding no lock. The completion thread calls vk_mem_collect
right after it publishes done and wakes the waiters, and pool_fini calls it for its own frees. collect_locked
(vk_upload_flush, shutdown) never drains the list. vk_mem_shutdown destroys what is left on it, after its
vkDeviceWaitIdle. The trace=perf "mem: sizes" GPU line counts the records destroyed this way.

Not reproduced before the change: the cost was summed from the completion thread's per-callback lines of the 0.10.0
log, not measured on a frame. Checks that passed: pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings,
gpa_check, mem_budget_check, tex_release_check, sync_hw_check, wb_lazy_check, mem_growth_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
b146ee716c Copy untouched lazy-heap pages as zeros in mem_copy_readable instead of faulting on them under frz_lock.
With NX2NAVI_HEAP_LAZY=1, a heap page nothing has touched yet is still reserved. mem_copy_readable holds frz_lock
and, for a range with no armed or probed region, copied it with a plain memcpy. A reserved heap page in that range
faulted into fault_veh, then access_fault, then heap_lazy_fault, which takes frz_lock on the same thread. The SRW lock
is not recursive, so the copy never returned, and every later fault, thaw and freeze waited behind it. Callers are
frame_rate.c's floor probe and vk_memory.c mirror_capture (MIRROR_CAPTURE_KB or MIRROR_STAGE).

Now, when the lazy heap is on and a range meets the heap, the range is copied run by run as plat_mem_state reports its
pages. The heap's reserved runs read zero, as they would once committed, and are not committed, so a diagnostic read
or a mirror's first copy does not undo what the setting saves. Readable runs are copied, and anything else leaves the
range out (ok = 0). Ranges outside the heap, and every range while the lazy heap is off, keep the copy that asks the
system nothing.

tests/heap_lazy_copy_check builds memory.c with the Windows platform layer and its fault handler. It copies an
untouched heap page, a range from written pages into untouched ones, a range past the heap's end, and a batch of
three, each on a worker the main thread gives 3 s. Before the change the first copy hung (exit 4). Checks that passed:
heap_lazy_copy_check, pass_cc_check on memory.c, no new -Wall -Wextra warnings, wb_lazy_check, mem_growth_check,
mem_budget_check and gpa_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
350c8b2111 Finish a new VRAM mirror's state under the pool lock hold that puts it on the mirrored list.
mirror_create put the pool on mlist under pool_lock, released the lock, and only then set acc_sub, mstate = MS_ON,
asked_room and twin. It also took pool_lock again for the device address table's hand-over (gpa_flip) or, with
NX2NAVI_GPA_LATE=0, the remap. pool_fini calls mirror_forget only when it finds MS_ON. A finalize on another thread
in that gap therefore left the record on mlist, and destroy_rec freed it and its buffer without taking it off the
list. The next hot_check, mirror_evict, vk_mem_mirror_begin or gpa_flip_cmds then read freed memory, and
mirror_evict could defer the freed buffer a second time. With GPA_LATE=0, the table record then named the destroyed
buffer. Now one hold covers the finalize check, the insertion, acc_sub then MS_ON, asked_room, twin, and the
hand-over or remap. twin_wanted and gpa_late are decided before the lock is taken, since each may read its setting and
log once. The untwinned counters are atomics and stay after the hold.

Not reproduced before the change: no check without a device can drive the race. Checks that passed: pass_cc_check on
vk_memory.c, no new -Wall -Wextra warnings, wb_lazy_check, gpa_check, mem_growth_check, mem_budget_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
688b250673 Let held_resume ask whether held work can go on from a published copy, not from a continuation another thread frees.
vk_exec_held_ready runs without sub_lock (vk_backend.c held_resume and submit_sync). It followed evq[s].head into the
continuation and read the wait in the continuation's private copy of the recording. Meanwhile another submitting
thread, inside vk_sync_submit, could run evo_resume and cont_free that continuation. A reused block then gave
vk_exec_event_met an event address pointing anywhere, so the unlocked reader could fault, not only read a stale state.

Now the holder of sub_lock publishes what the decision needs into a per-queue sequence lock (runtime/vk_evo_peek.h),
wherever a queue's first continuation, or that continuation's wait or stop, changes: in cont_push when the pushed one
becomes the first, in evo_resume after the head moves on, and after cont_walk stops at another wait. The copy holds
the wait by value. vk_exec_held_ready decides from a copy taken from one publish. It never waits for the writer:
after three tries that overlap a publish it answers "not ready", which is what losing that race answered before.
This fixes F-1-06 without the front-end lock. F-2-01's lock is still the answer for the other unlocked front-end
state.

Not reproduced before the change. Checks that passed: the new tests/evo_peek_check (one writer publishing 1,000,000
times while three readers copy: no copy mixed two publishes, and the decision cont_ready makes, with a stand-in for
vk_exec_event_met; with the sequence check removed it reports over a million mixed copies), pass_cc_check on every
runtime file (a header was added), no new -Wall -Wextra warnings in vk_exec.c, target_frames_check, oq_hold_check,
sync_hw_check, mesh_path_check and gpa_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
b755cdc536 Make the ASTC scratch trim only try the upload lock, so it can no longer deadlock with an upload.
vk_astc_scratch_trim runs at every present and held the decoder's lock while vk_defer_buffer waited for cmd_lock.
The upload path takes the two locks the other way round: vk_upload_begin takes cmd_lock, then vk_astc_record and
scratch_get take the decoder's lock. A present that trimmed while another thread was between vk_upload_begin and
scratch_get therefore hung both threads. Without the hang, the present still waited for as long as an upload flush
held cmd_lock through its fence waits. vk_memory.c gets vk_defer_buffer_try, which defers only when it gets cmd_lock
at once. When the lock is busy the trim leaves the scratch as it is, and the next present tries again.

tests/astc_trim_check builds the real vk_astc.c with platform/common.c's recursive lock. It checks that the trim gives
way while another thread holds cmd_lock, that it goes through once the lock is free, and that the forced interleaving
ends. Before the change the check reported the deadlock (exit 2). Checks that passed: astc_trim_check, pass_cc_check
on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_astc.c and vk_memory.c, and the pass's
test matrix (35 ok; the 6 failures are the baseline's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:40 -05:00
chrg6
a473e763b2 Tie the stutter map's cause count to perf.c's at compile time, and rewrap the comment that lists the causes.
The review of d84aa5fb and c05e6528 found that nothing at compile time tied perf.c FT_C_N to stutter_map.c SM_CAUSES:
they sit in different translation units, perf.c ft_frame passes stutter_frame n = FT_C_N, and the areas' by_cause
counts and cause_short are indexed against SM_CAUSES, so a cause added on one side only would mislabel the map without
an error (tests/stutter_hitch_check.c checks only its own names[]). stutter_map.h now defines STUTTER_CAUSES,
stutter_map.c sizes SM_CAUSES from it, and perf.c asserts FT_C_N == STUTTER_CAUSES beside the FT_C_* list. With
STUTTER_CAUSES set to 11 the assertion stops perf.c's compile.

The review also found that the header comment line of stutter_map.c listing the causes ran to 142 columns, where the
file wraps at 120; the paragraph is rewrapped without a change of words.

No change to what runs. perf.c, stutter_map.c and the other files that include stutter_map.h (romfs.c, vk_exec_draw.c,
vk_pipeline.c) compile with the dev flags, perf.c and stutter_map.c add no -Wall -Wextra warnings, and
stutter_hitch_check and stutter_map_check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
93be3e0665 Say in timed_alloc's comment that failed device memory allocations count in the mark.
The review of b4e12f1c found that vk_device.c timed_alloc calls perf_mark_end after a failed vkAllocateMemory too, with
0 bytes, so the "device memory allocations" mark's count now takes in failed attempts, which vk_headroom.c on_allocate's
old mark (VMA's callback after an allocation was made) never did. The time of a failed attempt held the frame as well,
and VMA may try again with a smaller block after it, so the mark keeps it; the comment now says that failed attempts
count with their time and 0 bytes and that the count can pass the number of allocations made.

Comment only. vk_device.c compiles with the dev flags and adds no -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
2d02ea664f Take only the render thread's own pacing out of the perf window's "render busy", and say that the pacing share counts other threads' waits.
The review of d84aa5fb found that perf.c perf_present's window line computed "render busy avg" as the window minus
wait_ns[PERF_PACE], and that since d84aa5fb the pacing sum also holds the acquire's wait of a game that acquires its
window images on another thread (perf_end_any). That wait can overlap the render thread's own work, so for such a game
"render busy" was understated and the "render thread blocked" shares could add up to more than 100%. perf_present now
keeps the other threads' pacing it folds in over the window (pace_any) and takes only the rest out for "render busy",
as before d84aa5fb; the pacing share and the "bound:" rule keep the whole sum, which is what makes the acquire's pacing
visible. A comment there says that the shares of such a game can pass 100% and that the one-time "nvn: the game
acquires" line tells when that applies. The line's format is unchanged.

The review also asked that perf_end_any's comment cite a function: it now names vk_lowlat.c vk_lowlat_pace's comment.

Diagnostics only: the window line is logged with trace=perf. Not reproduced (it needs a game that acquires on another
thread). perf.c compiles with the dev flags and adds no -Wall -Wextra warnings; perf_summary_check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
ec967d4daa Say in perf_census.h that the census charge assumes the walk held the replay up, and say in the ini when the walk line is left out.
The review of c05e6528 found two texts that said more than the code or the logs do.

- runtime/perf_census.h stated as fact that the replaying thread's write-watch queries wait for the walk's address-space
  lock. The verifier's caveat stands: in the session the charge is based on, the presenting thread's 600-frame memory
  report (vk_memory.c vk_mem_census) ran at the same frames as the walks, and the log cannot tell which of the two held
  the 63 replay hitches up. The comment now says they sat beside such a walk, that the charge assumes the walk held them
  up, and that a play-test with mem_census = 0 checks it.
- nx2navi.ini.example's mem_census paragraph said the "mem: process" line comes every 600 frames. Since c05e6528,
  vk_memory.c heap_log leaves its own walk out at a frame where perf.c census_check walked (every 3600th frame, and a
  commit census on growth that falls on a 600th), and the commit census lines stand in for it; the paragraph says so.

Comment and settings text only. perf.c compiles with the dev flags; census_charge_check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
28f420bf03 Take a census walk seen running for over 2 s as a torn read and charge nothing to it.
The review of c05e6528 found a window in perf.c ft_frame: it loads walk_t0 and then walk_t1, while perf_census_walk(1)
stores walk_t1 = 0 and then the new start. A walk that starts between the two loads leaves ft_frame with the previous
walk's start and an end of 0, so perf_census.h census_charge took that walk as running since then, the whole frame as
overlapped, and moved all its replay and unattributed time to "census". The window is a few nanoseconds, but the error
is a whole frame. A walk takes 100-260 ms, so census_charge now returns 0 for a walk seen running for longer than
CENSUS_WALK_MAX_NS (2 s).

tests/census_charge_check.c gets a torn read (a start 10 s back with an end of 0), a walk running for 1.9 s, which still
counts, and one running for 2.001 s, which does not; its random frames now reach walks more than 2 s old and check the
moved time exactly (the overlap capped by the frame's room and its replay and unattributed parts) instead of only its
bound. Built against the header before this change, the check fails the torn read, the 2.001 s walk and the first such
random frame; with it, it passes. docs/building.md names the new case.

Diagnostics only: the census part exists only with NX2NAVI_MEM_CENSUS = 1. Not reproduced in a run (the window is a
few nanoseconds). perf.c compiles with the dev flags and adds no -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
b7c678540e Give the frames presented at the start and end of a census walk on the "mem: process" line, not seconds since boot.
The review of c05e6528 found that vk_memory.c census_main printed the walk's start and end as kern_now_ns / 1e9, which
is plat_now_ns: seconds since Windows started. No other log line uses that clock (the stutter lines give seconds since
the first frame, the slow-frame and present lines frame numbers), so the walk could not be set beside the frames it held
up, which is what a play-test with mem_census = 0 needs to tell the walk from the presenting thread's report as the
cause of the 600N+1 hitches. census_main now reads span_frame, the frame vk_mem_census sets at every present, beside
each end of the walk and logs "the walk from frame F0 to frame F1, N ms": the numbers the "stutter: frame" and
"perf: slow frame" lines carry. The walk's length stays.

Diagnostics only: the line is logged with NX2NAVI_MEM_CENSUS = 1, which defaults to 0. Not reproduced (it needs a game
run). No tool or test reads the line. vk_memory.c compiles with the dev flags and adds no -Wall -Wextra warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
a2255cfc5a Drop the false reason from present_main's single read of the trace flags.
The review found that the comment in vk_backend.c present_main and the message of 3236dc3c gave a cause that cannot
happen: the menu cannot turn trace=perf on between the two reads. rt_trace is set once at start (main.c parse_trace),
TRACE is a RESTART row in cfg.c metas, no menu code changes it, and the only other writer, ipc.c probe_sdk_client,
runs in a probe mode and changes only the IPC bit. The single read stays, since the stamps and both lines then follow
the same flags; the comment now says only that.

Comment only. vk_backend.c compiles with the dev flags and with -Wall -Wextra without new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
9d8f698b46 Read the trace flags once per present in the present thread, so a switched trace cannot mix timed and untimed stamps.
present_main (d84aa5fb) takes the blit and present-call stamps only while trace=present or trace=perf is on, then
read rt_trace again to decide whether to log. The menu's performance log can turn trace=perf on between the two
reads; the first window would then add the difference of a stamp and 0, a wrapped figure. The flags are now read once
at the start of each present and used for the stamps and both lines.

Diagnostics only. vk_backend.c compiles with the dev flags and with -Wall -Wextra without new warnings;
perf_summary_check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
48ea624389 Bring the stutter map's ini text, the checks in docs/building.md and the released-textures bound line in step with the code.
- nx2navi.ini.example said the stutter map is written every 10 minutes and at exit; since 7b0dbeb1 it is every 5
  minutes of play (stutter_map.c SM_REPORT_NS), at a focus loss in play and at exit, and since 2f640fb8 a run of
  consecutive long frames counts as one hitch with their summed time. The paragraph now says both.
- docs/building.md had no paragraph for tests/freeze_hold_check.py (0e0cfe08), while each other header-alone check has
  one: it now says what a change to runtime/freeze_hold.h or vk_memory.c freeze_pass must pass and what it checks.
- vk_texture.c released_cap_bytes named "a thirty-second of the installed memory" as the source of the bound even when
  plat_mem_info gave no size and the bound fell back to 1024 MiB; that case now reads "the default without the
  installed memory's size".

Text only. check_content passes; freeze_hold_check passes; vk_texture.c compiles with the dev flags and with -Wall
-Wextra with the one warning it had before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
395f57a2fa Count the hitch in the run's last frames in the stutter map's exit report.
A hitch is decided only after the two frames that follow it, and an episode closes only at the next frame that is not
a candidate (stutter_map.c cand_decide, episode_close). The exit's report summed the areas as they were, so a hitch in
the last two frames before the exit, or a run of long frames ending there, was never counted: the audit's probe
(Av/stutter_exit_probe.c with the real stutter_map.c) printed 0 hitches for a 150 ms frame followed by 0, 1 or 2
frames, and 1 only from 3 frames on. The periodic and focus-loss reports only count it later; the exit loses it.

stutter_report("exit") now adds to its printed totals what the run would have counted: the open episode as
episode_close counts it (a hitch, or two frames or more), and the candidates still waiting, where one over
SM_HITCH_SURE_NS counts whatever follows and each joins the episode of the frame before it as cand_decide joins it. A
waiting candidate under the sure line is left out, as it may be a change of pace. The episode's area decides play or
load, and its first frame's cause goes into the by-cause totals. exit_tail only reads this state: the exit's report
runs on another thread (perf.c perf_summary_flush from the window's close or the game's exit) while the presenting
thread, which owns ep, the candidates and the areas' play counts, may still be in stutter_frame; closing the episode
from there would race it. The per-area lines and the other reports are unchanged.

tests/stutter_hitch_check.c checks six tails (a 150 ms frame followed by 0, 1 or 2 frames, two 150 ms frames, two
with one frame after them, and 87/91/96 ms): the exit's report prints one hitch more than the areas hold, the same
count the run reaches once it goes on. All six printed one fewer before the change. stutter_hitch_check and
stutter_map_check pass; stutter_map.c compiles with the dev flags and with -Wall -Wextra without new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
110c1b24f9 Make the stutter map's report copy the map under its lock and sort and log the copy without it.
stutter_map.c stutter_report held sm_lock from its start to its end: through a qsort over up to SM_PIPES (4096)
pipeline entries and up to about 85 log lines. The replay thread takes sm_lock in stutter_miss, and the pipeline
workers in stutter_ready from inside vk_pipeline.c's P.lock, which the draw path takes too (ready_note), so a worker
waiting for sm_lock while it held P.lock could hold up a drawing thread for the length of a report. Since 7b0dbeb1 the
report runs every 5 minutes instead of 10 and at a focus loss in play, on the presenting thread.

The report now copies the areas (at most 128), the used pipeline entries and the two lost counts into one malloc'd
block under sm_lock, releases it, and sorts and logs from the copy; the static pointer list it sorted is gone with it,
so two reports at once (the exit's and a periodic one) no longer share it. The areas' play counts still belong to the
presenting thread; copying them under sm_lock does not change that. perf.c session_report's comment now says the map
is copied under its lock.

The output text is unchanged: a scratch harness that ran the same frames, misses and ready notes (6 areas, 300 draws)
through the old and the new stutter_map.c printed identical reports, 50 lines each. Not reproduced before the change
(the hold is estimated under a millisecond, through the log queue). stutter_hitch_check and stutter_map_check pass;
stutter_map.c and perf.c compile with the dev flags and with -Wall -Wextra without new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
7684734b47 Log once per switch when rt_knob reads a value other than 0, 1 or ab as on.
perf.c rt_knob reads "0" as off, "ab" as the A/B blocks and everything else as on, without a word: "cache_die = off",
"crash_filter_guard = false" and "one_per_core = 0 ; x" all read as 1 (the audit's probe, Fv/knob.exe, cut rt_knob out
with cfg.c: three 1s and no log line). A player who wrote such a value meant the opposite and had no way to learn it
from the log.

rt_knob now logs "cfg: NX2NAVI_<name> = '<value>' is not 0, 1 or ab: read as 1" the first time it reads such a value
for a switch. The value still reads as 1: reading words as 0 changes what a written setting does and waits for
approval with F-8. Most of the 109 call sites keep the result in a static but some read it per call, so the warned
names are kept in a table of 32 pointers (the names are string literals) filled by compare-and-swap; 0, 1 and ab pay
only one more string compare.

New tests/rt_knob_check.py cuts rt_knob and its table out of perf.c and builds them with stand-ins for the settings and
the log: 0, 1, ab, empty and unset read as before without a line; off, false and "0 ; a note" read as 1 and log once
each however often they are read. docs/building.md describes it. The fault was shown before the change by the audit's
probe; rt_knob_check passes, and perf.c compiles with the dev flags and with -Wall -Wextra without new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -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
f687ede724 Leave waits for value 0 out of the count of game waits that returned with event-ordered work held.
vk_backend.c synced_held_count counts the game's fence waits that return while a submission stopped at an event wait
is still held, to decide whether the value should be signalled only after the held remainder (F-2-05). The window
acquire's sync is fenced with value 0 (nvn_api_obj.c nvnWindowAcquireTexture), so a game's wait on it reached the
counter as a wait for 0, which no held submission signals: 6 of the 12 "a game's wait for N returned with K
event-ordered submission(s) still held" lines in the two 0.10.0 fresh sessions were "wait for 0". The counter now
returns at once for value 0, before counting.

Not done here: counting only the waits for values at or after the oldest held submission, which needs its value kept
in vk_exec.c's continuations. Diagnostic only. Not reproduced before the change (it needs a game run).
vk_backend.c compiles with the dev flags and with -Wall -Wextra without new warnings; frame_step_check passes.

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
26b7cfd658 Put the process's OS handle count on the sizes line and add a handle census by kind behind NX2NAVI_HANDLE_CENSUS.
A session of 2026-11-13 grew from 683 to 1095 OS handles in 11 minutes, rising at every area load and never falling,
while the guest's own handles stayed at 116-122 and the threads flat; the VMA block count fell back while the handle
count did not. The log could not show it: the 600-frame "mem: sizes" line had only the guest's handles, and nothing
told which kind of handle grows.

kernel.c kern_sizes now adds "process handles" (plat_handle_count, one system call every 600 frames under trace=perf)
beside the guest's "handles". platform.h gets plat_handle_census: on Windows the system's extended handle table
filtered by our process id, with the type names from NtQueryObject's ObjectTypesInformation; on Linux the
/proc/self/fd links grouped by kind. It never opens, duplicates or names a handle (a name query can block on a pipe).
With NX2NAVI_HANDLE_CENSUS=1 (default 0, read at start) and trace=perf, perf.c starts one long-lived thread that the
600-frame report wakes; when the count grew by 16 or more since its last census it logs "mem: handles frame N: T in
all (+D since the last census): Event a (+x), ...". The census reads a few MiB of the system's table in 2-5 ms, off
the drawing threads; one thread for the run keeps a new thread's own handle out of what it counts. New cfg rows:
HANDLE_CENSUS in known[], sections[] (diagnostics) and metas[] ("0", "0|1", restart), and an nx2navi.ini.example
entry.

New tests/handle_census_check.py builds the platform layer alone: 20 events and 5 sections show as Event +20 and
Section +5, the total stays within 2 of GetProcessHandleCount, the figures return once they are closed, and kinds
past the room given go into the last entry; docs/building.md describes it. Not reproduced before the change (the
growth needs a game run). handle_census_check, sync_wake_check and the cfg row check pass; every runtime file compiles
with the dev flags, and perf.c, kernel.c, win32.c and cfg.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
baf83fda6a Make a stutter-map episode end at its last frame that stood out once the frames after it settle into a pace.
At 40 fps or less every frame is over 25 ms and so a hitch candidate. A frame that stood out opened an episode with
its hitch flag set, every following paced candidate joined it as a consecutive frame, and the cut after
SM_EPISODE_MAX frames applied only to episodes without a hitch. So the episode grew until the frame rate rose again:
a 150 ms frame followed by 300 frames at 30 fps counted one hitch of about 10 s, and five 120 ms hitches at a steady
30 fps counted as one.

cand_decide now keeps the episode up to its last frame that stood out (frame, summed time, frame count). When more
than SM_EPISODE_MAX paced frames follow that frame, the paced frames are a change of pace and the episode ends at the
frame that stood out and is counted with its time to there. An episode without such a frame is cut as before, and
episode_close is unchanged, so the 87/91/96 ms episode of 2f640fb8 keeps its 274 ms.

tests/stutter_hitch_check.c gets both cases: 150 ms then 300 frames at 30 fps counts one hitch of 150 ms, and five
120 ms hitches at 30 fps count five of 120 ms each. Both failed before the change (0 hitches counted while the run
lasted). stutter_hitch_check and stutter_map_check pass; stutter_map.c compiles with the dev flags and with -Wall
-Wextra without new warnings.

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

rtss_limit_check passes 27 of 27, check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
fb648acef9 Say where frame generation's acquire queue comes from when the graphics family has one queue.
The review found vk_fg.cpp's header saying both of frame generation's queues come from another family that can present
when the graphics family lacks them. vk_device.c create_device takes only the present queue from a family that can
present; the acquire queue, which needs no capability, is a second queue of that family or of any other family, or the
present queue itself when there is none. The header now says so.

Comment only: vk_fg.cpp compiles, check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
2150458a01 Pass the unlimited frame rate's RivaTuner Statistics Server want as it is.
The review found that frame_rate.c frame_step_cap_ns's unlimited branch handed rtss_limit_want "rtss_out ? rtss_out :
0", which is rtss_out itself: a kept-out reason (RTSS_OUT_*), or 0, which clears RTSS's limit. It now passes rtss_out,
and the comment says what each value does. Same want, same code: frame_rate.c compiles, check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
130fa90590 Drop the closed input that rtss_decide.h had and the worker never set.
The review found RtssIn.closed and its RTSS_WHY_CLOSED rule unused in the game: rtss_limit.c worker builds a poll's
view with the want alone, after it has skipped the poll under the lock once rtss_limit_restore ran, so the pure check's
closed case pinned a rule the worker never reaches. The field, the rule and the reason go; rtss_decide's comment says
that no poll decides after the exit and where that is done. The worker's skip stays as it was, and
tests/rtss_worker_check.c's restore_* scenarios (restore_inflight among them) keep covering it. The pure check's
closed_and_library becomes unset_and_library.

No decision changes: rtss_limit_check passes 27 of 27, check_content passes.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
fa229f94f8 Say that a later limit loads RivaTuner Statistics Server's hooks library in a run kept out at its start.
The review found docs/architecture.md claiming that a run kept out of RTSS from its first present never loads the hooks
library, its thread only reading RTSS's settings. That holds only for NX2NAVI_RTSS=0: once frame generation or low
latency is switched off, a limit is wanted, rtss_decide.h rtss_wants_hooks lets the poll load the library, and the
limit goes into the profile as usual (tests/rtss_worker_check.c kept_lowlat_then_limit). The paragraph now says that
the thread reads only while RTSS is kept out, that a later limit loads the library, and that NX2NAVI_RTSS=0 keeps the
thread read-only for the run; it also says the profile gets back what it held before the run changed it. The same claim
is corrected in rtss_limit.c's header and read_settings comments, in rtss_decide.h rtss_wants_hooks's comment, and in
find_rtss's start-up line for a kept-out first want, which now says the thread only reads while RTSS is kept out.

Docs, comments and one log line's wording: rtss_limit_check passes 24 of 24, check_content passes.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
3b250adffd Describe the frame pacer, the one-limiter rule and RivaTuner Statistics Server as they behave now.
docs/architecture.md mentioned the pacer only inside its low latency paragraph and RTSS nowhere. It now has the pacer
(the limit counting drawn frames, the 240 ceiling, movie rates, the slots and their catch-up, the present thread's
wait and pace_smooth, the acquire's waits, the step snapped to the paced rate), the one-limiter rule as the goal with
the exceptions that stand today (pace_smooth under RTSS, DLSS Frame Generation's Reflex beside the pacer, no limit at
all from the pacer while RTSS holds it through movies and windows with no present, a limit set in RTSS itself while
RTSS is kept out), RTSS's part (the lookup on its thread, the profile write and read-back, when the pacer stands aside
and when it keeps the limit, the kept-out runs that only read RTSS's settings, the restore on orderly exits and what a
kill leaves), and how to measure pacing (the present and perf traces, perf_summary, PresentMon as the reference, the
runtime's own hitches). The changes that wait for approval are not described as done.

nx2navi.ini.example's rtss paragraph adds low latency, and the second limiter a limit set in RTSS itself makes with
rtss = 0, frame generation or low latency, which the log and the settings menu now point out. README.md's frame
generation item ends its sentence on RTSS with what it does and the same note.

Docs only; check_content passes.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
7613b7ade6 Bring the comments on frame generation, its restarts and RivaTuner Statistics Server's exit up to date.
vk_dlssg.c said frame_rate.c divides the frame limit by the multiplier, but the limit counts the frames the game draws
and only a limit of display is divided (frame_rate.c limit_game_hz). vk_fg.cpp said FSR 3.1 frame generation applies
after a restart, but FRAMEGEN is live (gfx_present.c window_settings_live makes the swapchain again; only a device made
for DLSS Frame Generation waits for a restart, vk_fg_wanted), and that its two queues come from the graphics family,
while vk_device.c create_device takes them from another family that can present where the graphics family has one
queue. rtss_limit.c's header said the profile is put back "from every path that ends the process" and stays "until the
next run changes it", and named only the setting and frame generation as keeping RTSS out: it now names the exits that
skip the restore (a kill, the device-lost watchdog, a crash inside the crash handler), that the next run takes the
leftover limit for the player's own, low latency, the read-only thread of a kept-out run, and the lookup on the
worker. (rtss_limit_want's "both reasons need a restart" went with the change that starts the worker for a kept-out
first want.)

Comments only: the three files compile, and check_content passes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
2f640fb8ed Make the stutter map count runs of long frames as one hitch and the texture pool's area loads as loads.
In the 2026-11-13 session three frames of 87, 91 and 96 ms after an area load each counted as a change of pace, as each was followed by one as long, and loads that only the texture pool saw (no area file read, so no scene line) were charged to play. Consecutive long frames are now one episode, counted once with their summed time, its line giving that time and the first frame's parts; a run of more than 8 long frames none of which stands out is still a change of pace. The texture pool's start and end of an area load mark an area load for the map, as the game's own loads do.

With trace = perf, the slow write-watch queries and freezes past the 200 lines they may log are counted and given every 60 frames, so a late area load still shows them; the session's lines had run out by frame 36,387.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
7b3edac140 Log the GPU's timed parts of a frame whose render thread waited 20 ms or more for fences.
In the 2026-11-13 session frames of 60-96 ms came 2 to 6 frames after each area load with the render thread waiting 59-86 ms for the GPU, and no line said what the GPU was doing. With trace = perf such a frame now gets a line at the next present: the timed parts that ended in its interval, their GPU time, the longest gap between two, and the longest parts with their kind (draws, copies into VRAM mirrors, pre-passes, uploads, presents) and their time in the queue. The completion thread keeps the last parts in a ring as it reads their timestamps; nothing waits for the GPU. Measurement only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
67cef75920 Make the memory report's system line name the write-combined views, the recording copies and the handles.
The system line took the driver's device-local figure out of the process's commit, but the driver's write-combined views of that memory came to 0.2-1.1 GiB more, and that part went to "other": in the 2026-11-13 session other system memory grew 1,283 MiB in play while the process's own commit grew 307. With a walk of the address space (mem_census) less than a minute old, the gap at that walk is now a term of its own. The NVN recording copies, malloc'd, count among the C runtime heap's known parts with those unused for 5 minutes, and the line ends with the process's handle count, which rose at every area load in that session and never fell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
cacb1318fc Count the NVN recording copies no submission used for 5 minutes, without walking them.
A recording copy is retired only when the game adds its control memory again, and in the 2026-11-13 session the live copies grew from 9,138 (0.7 MiB) to 223,753 (58.3 MiB) in 7 minutes of play, about 38,000 more at each return to an area: the one real host-memory leak of that session. Each copy now carries the minute of its last use (made, called or submitted), and counters by minute give how many copies no submission used for 5 minutes, and their bytes, in the 600-frame sizes line, with no walk of the copies under the lock the recording threads take. Measurement only: retiring such copies needs this figure first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
5354aecdc5 Name the term that set the released textures' size bound in its log line.
The line said "a quarter of the video memory budget" whenever the card's rule raised the bound, also when the smaller term was an eighth of the installed memory: on the RTX 5090 with 32 GB of the 2026-11-13 session the bound of 4,008 MiB came from the memory, not from the card's 7,854. The line now names the term that won, and the default's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00