With the replay on the submitting thread, a present that waits for event-ordered work (vk_backend.c PH) goes out after
the submission that replays the last of that work, at the next present, or after PH_MAX submissions. The game submits
from several threads while one of them presents, so that submission may be another thread's. PH had no lock: two
submitting threads could both pass ph_flush's check of PH.on and put one frame out twice; a thread reading PH's fields
while the presenting thread armed the next frame could put out a job mixed from two frames; and a held frame put out on
a submitting thread could reach the present thread after the presenting thread's next frame, a picture back in time.
runtime/vk_present_held.h now holds PH's job, its arming, putting out and counts behind PH.lock. Submitting threads
only try the lock (ph_flush_try) and go on when another thread holds it: that thread is putting a present out or arming
one, and the next submission, the next present or PH_MAX checks again, the bound there was before. The presenting
thread takes it for every synchronous present while held presents are on (ph_flush_next, ph_present), so the
synchronous present_image calls run one at a time and in the game's order. That is the one new wait, and only the
presenting thread makes it: only while another thread puts a held present out, for at most that present_image. It is
counted (lock_waits, lock_wait_ns, lock_wait_max) and timed as PERF_DRAIN. No path holds the lock across held_resume,
which is a replay. Neither 0.10.0 fresh log has a held present at all ("went out" and "presents that waited" lines),
so the wait is not expected in normal play. present_held = 0 turns held presents off, and with them every use of the
lock.
Not reproduced before the change. Checks that passed: the new tests/present_held_check (one presenting and three
submitting threads over 200,000 frames: every frame out once, in order and never two at once; the unlocked 0.10.1
shape through the same harness gave about 205,000 presents for 200,000 frames, 6,000 to 8,000 out of order or twice and
8,000 to 12,000 at the same time; step-by-step cases; a submitting thread gives up on a held lock at once and the
presenting thread's wait is counted), pass_cc_check on every runtime file, no new -Wall -Wextra warnings in
vk_backend.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nvnQueueSubmitCommands' q->submits++, vk_backend.c synced_held_count's two counts and vk_exec.c vk_exec_gate_wait's
window (GW) run on the calling guest thread without a lock, and the game submits and waits from several threads (five
in the 0.10.0 fresh session). A plain ++ there loses counts, and two threads could both reach a window's end and log
it, or reset it under each other. Now each count is an atomic add; the line is logged by the thread whose add reached
it, and the gate's window is taken with exchanges by the thread whose add made it 600, so adds that come in meanwhile
count in that window or the next. Nothing reads q->submits today; the counts only feed log lines.
Not reproduced before the change. Checks that passed: pass_cc_check of vk_exec.c, vk_backend.c and nvn_api_core.c with
no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setting_presents says how many more presents a new scene size counts as the result of a settings change (the resize
hold of vk_backend.c frame_hold). vk_dlaa_frame sets and counts it down, and vk_dlaa_resized_take tests and clears it
at the same present. With the replay on the submitting threads the frame's bookkeeping, vk_dlaa_frame among it, can run
on another thread than the presenting one (another thread's replay, or the next submission once the bookkeeping goes in
replay order), so a plain test-then-clear beside a plain decrement could take the hold twice or miss it. Now the take is
two exchanges, which give the outcome of the old two statements, and the count-down is a compare-and-swap that stops at
0, so it never goes below a clear made meanwhile.
Not reproduced before the change. Checks that passed: pass_cc_check of vk_dlaa.c with no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pending_draws (vk_exec_draw.c) counts the draws a frame left out because their shader or pipeline was still building;
vk_backend.c frame_hold takes it at the present to hold such a frame back. The comment said both happen on the replay
thread, which is true only with the replay on its own thread. With the replay on the submitting threads, draw_skipped
adds on the thread holding the submission lock (and its parallel chunks) while vk_exec_pending_draws_take reads and
clears it on the presenting thread, so a plain ++ and a read-then-clear could lose a count: a frame missing draws shown
instead of held, or the next frame held for nothing. The add, the take (an exchange) and the read that skips a link
wait are atomics now.
Not reproduced before the change. Checks that passed: pass_cc_check of vk_exec_draw.c with no new -Wall -Wextra
warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The replay asks whether a frame that skips a draw would be held (vk_exec_draw.c, vk_frame_hold_left) on whatever
thread holds the submission lock, and with the replay on the submitting thread that need not be the thread that
presents. vk_frame_hold_left refilled FH itself (frame_hold_refill), so a refill on a replaying thread beside the one in
frame_hold on the presenting thread wrote left_ns and last_ns from two threads: an interval counted twice or not at all,
which decides whether a frame with skipped draws is held. Now the reader computes the same arithmetic from the stored
fields (fh_left_at) and writes nothing; the limit is read once with a compare-and-swap whose winner logs it; left_ns,
last_ns and holding are stored and loaded as atomics. Refilling in two steps or one gives the same allowance, so the
single-thread case is unchanged.
Not reproduced before the change. Checks that passed: pass_cc_check of vk_backend.c with no new -Wall -Wextra
warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_exec_held_ready runs without sub_lock (vk_backend.c held_resume, submit_sync). It decided from the published copy
since 688b2506, but tested the wait with vk_exec_event_met, which compares at the copy point of the submission being
recorded (vk_exec_xfer.c ev_mem, ev_sync_seq). That point is set by whichever thread holds the lock, so a thread asking
while another one recorded tested "at sync" against that other submission's copies. Its question is whether held work
can go on in a submission of its own, which takes its copies later: vk_exec_event_met_now tests the value in memory now
or the signals replayed so far. Under the lock nothing changes: evo_resume and cont_ready keep the copy-point test, and
vk_exec_record asks right after its own copy point, where the two agree.
evo_pending and vk_exec_submit_seq are written under the lock and were read as plain values by vk_exec_held,
vk_exec_held_ready and the held present's since and PH_MAX checks; they are now stored and loaded as atomics
(vk_exec_submit_seq_now), so the compiler cannot keep a stale copy in an unlocked reader.
Not reproduced before the change. Checks that passed: pass_cc_check on every runtime file (a header changed), no new
-Wall -Wextra warnings in vk_exec.c, vk_exec_xfer.c and vk_backend.c, evo_peek_check, target_frames_check and
oq_hold_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 9071dfc3 found that nx2navi.ini.example, cfg.c's metas comment, the commit message and docs/architecture.md
said the rollback switch works "as up to 0.10.1", which it does not exactly. With 1, every place that lets something go
waits for the device again, C-5's queue-lock hazard with it, but what was let go of still goes through vk_dlaa.c's list
and vk_memory.c's deferred destruction, the motion vectors and the HUD-less picture keep their 8-present gate,
vk_upscaler_settle leaves what the submission being recorded names, stale_sizes waits once per call, and a refused DLSS
creation with features let go of still listed waits and tries again, where 0.10.1 turned DLAA off. Each difference is
the safer side. The ini line now says that the game waits at each change as up to 0.10.1 while what was let go of
still goes once the card is past it; cfg.c, vk_dlaa.c's "what the upscalers let go of" and docs/architecture.md say
the same. Comments and docs only.
Checks that passed: cfg_rows_check (0 problems), upscaler_idle_check, cfg.c and vk_dlaa.c compile with -Wall -Wextra
and no warning, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 9071dfc3 found that release_kept counted the features "released once the GPU is past their last frame"
as every DLSS feature left in the list of what was let go of, capped at the call's count, on the claim that older
entries had older fences and went first. That does not hold: an entry let go of earlier can have a later fence than
this call's (a size evaluated in a later submission and then freed), so the line could credit an earlier let-go to
this call. release_kept now keeps the handles of the features it lets go of and counts those still in the list after
vk_upscaler_let_go: a feature goes into the list once and leaves it only through its release, and no feature is made in
between, so such a handle is one of this call's. With upscaler_idle_wait = 1 that is 0 unless a feature was evaluated
in the submission being recorded, which no wait can pass yet. Only the log line changes.
Checks that passed: vk_dlaa.c compiles with -Wall -Wextra and no warning, upscaler_idle_check,
tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 9071dfc3 found the comment stale: it said the new prebloom images were left at PB.used 0 by a clear of
the old ones, but retire_pb now keeps PB.used, and the PB.used = D.presents at the top of pb_images already covers the
new images on that path. The assignment after the images are made stays as the guard against any clear of PB before
it (pb_destroy would zero PB.used), which upscaler_idle_check holds. The comment now says that. A comment only: the
code is unchanged.
The review also noted that 77756b21 alone left a window between pb_destroy and that assignment in which PB.scene was
set with PB.used 0; 9071dfc3, where retire_pb takes pb_destroy's place on that path, closes it, so nothing changes
here for it.
Checks that passed: upscaler_idle_check, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The review of 2b9b3061 found that the check missed the dangerous regression. It only checked that each place that lets
something go calls vk_upscaler_let_go, so with destroy_size put back in vk_dlaa.c free_size, or hl_destroy in
hudless_from's size change, it still passed, while either frees images or a DLSS feature a frame still in flight uses.
The check now also fails when:
- a function that lets go of what the GPU may still use (vk_dlaa.c free_size, free_snap, stale_sizes, release_kept,
retire_idle, evaluate and the retire_size, retire_feat, retire_snap, retire_hl and retire_pb helpers; vk_ffx.cpp
context_for, trim, retire_up, vk_ffx_trim_idle and vk_ffx_release_idle; vk_ffx4.cpp context_for, vk_ffx4_trim,
retire_up and vk_ffx4_release_idle) calls a destroy that frees at once (destroy_size, destroy_snap, hl_destroy,
pb_destroy, feat_release, N.release, vkDestroyImageView, vmaDestroyImage; A.destroy in the FSR files);
- hudless_from, pb_images or snap_for call one of those before their first image2d call (after it, only their half-made
new images are destroyed);
- upd_take_all, which releases a whole list whatever its gates, is called outside teardown (vk_dlaa_release,
vk_ffx_release, vk_ffx4_release) and a full list's fallback (dead_put, the FSR files' retire_up), or there before
vk_upscaler_settle's wait for the device.
docs/building.md "Checks" says so.
Each of 19 mutations of scratch copies fails the check (destroy_size in free_size and retire_idle, hl_destroy in
hudless_from, pb_destroy in pb_images, destroy_snap in free_snap and snap_for, feat_release in release_kept, N.release in
stale_sizes, vmaDestroyImage in retire_size, upd_take_all in retire_hl, A.destroy in FSR 3.1's retire_up and in either
file's trim and release_idle, dead_put and FSR 4's retire_up without the settle, FSR 3.1 and FSR 4 without the retry);
the unchanged
sources pass, and stability-pass's sources fail with 46 problems. tools/check_content.py --staged passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 09027478 found that FSR 3.1 lost its fallback for a refused creation. Up to 0.10.1, vk_ffx.cpp
context_for waited for the device and destroyed the other render size's or the least recently used context before
create_counted, so their video memory was free for the new one. Since 09027478 they only go into g_dead, and the drain
in vk_upscaler_let_go destroys those whose last upscale the GPU is past. When it is still behind and the library
refuses the new context for want of memory, context_for returned nullptr, vk_ffx_upscale 0, and evaluate's dl_fail
turned DLAA off for the run. DLSS (evaluate) and FSR 4 (context_for) already wait for the GPU to pass what they let go
of and try again.
context_for now does the same when the creation is refused and FSR 3.1's or FSR 4's list of contexts let go of is not
empty: vk_upscaler_gpu_wait (a timeline wait for those submissions, not the device's idle; the device with
upscaler_idle_wait = 1), vk_ffx_dead_take, and a second create_counted when something was destroyed. A line says the
refusal, the wait and the outcome. upscaler_idle_check now also fails when vk_dlaa.c evaluate or vk_ffx.cpp or
vk_ffx4.cpp context_for has no vk_upscaler_gpu_wait (a scratch copy without the retry fails it), and docs/building.md
says so.
Not reproduced before the change (found by review). Checks that passed: vk_ffx.cpp compiles with -Wall -Wextra and no
warning, upscaler_idle_check, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/upscaler_idle_check.py reads vk_dlaa.c, vk_ffx.cpp and vk_ffx4.cpp with their comments and strings blanked and
fails when vkDeviceWaitIdle is called outside vk_upscaler_device_idle and the --probe-ffx-cache path, when
vk_upscaler_device_idle is called outside vk_upscaler_settle and vk_upscaler_gpu_wait, or when one of the ten places
that let something go does not call vk_upscaler_let_go, through which NX2NAVI_UPSCALER_IDLE_WAIT=1 brings the old wait
back. It also checks that switch's sections[] entry and nx2navi.ini.example line (cfg_rows_check has its known[] name
and metas[] row), that perf.c names every PERF_M_* kind (a kind without a name prints a null string on a slow frame's
line), and that pb_images notes PB.used after the last call that clears PB.
On this branch it passes. On the base of this branch's work (stability-pass, 1f547853) it reports the twelve waits of
C-6 and Cv-1, each with its function, and on the first commit of this branch, where the waits went through
vk_upscaler_device_idle, the same twelve places. docs/building.md "Checks" describes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_ffx.cpp context_for (two places) and vk_ffx4.cpp context_for (three) waited for the whole device to be idle, with
the queue lock held, on the thread that replays the game's submissions, before they destroyed an upscale context of
another render size, the least recently used one for a new output size, or (FSR 4) every idle one after a refused
creation: a stall of the present's flip, the completion thread and upload submits at every pause-screen size, reload,
window size, dynamic resolution step or mode change with FSR on.
A context that goes is now let go of (retire_up): it goes into a list (vk_up_defer.h) with the timeline value of the
submission of its last upscale (Up.fence, set after each dispatch) and is destroyed on the same thread once the GPU is
past it, at the points where vk_dlaa.c takes its own list (every evaluation, every pass while none runs, and after
something is let go of). The 90-second trims, vk_ffx_trim_idle and vk_ffx_release_idle (the upscaler off, which no
longer needs retire_idle's wait) let go of theirs the same way. FSR 4's create descriptors, which the API asks to stay
live until the context is destroyed, move from the slot into a calloc'd Made that goes with the context, so the slot
can take a new context while the old one waits. A refused FSR 4 creation now waits on the timeline from the host for
the contexts let go of, no queue touched (vk_upscaler_gpu_wait), and tries again when any went; with
NX2NAVI_UPSCALER_IDLE_WAIT=1 that wait and every let go wait for the device as before. docs/architecture.md says
where the upscalers' releases go.
C-5 is narrowed: on the default path no upscaler waits for the device any more, so these waits no longer touch the
frame generation swapchain's own queues; vk_fg.cpp, vk_fg4.cpp, gfx_present.c, vk_hdr.c, vk_fsr.c and vk_color.c keep
theirs.
Not reproduced before the change (no FSR session log here). Every runtime file compiles, vk_dlaa.c, vk_ffx.cpp and
vk_ffx4.cpp with -Wall -Wextra and no warning; vk_ffx4.cpp's non-Windows stubs compile against vk_ffx.h;
up_defer_check passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_dlaa.c waited for the whole device to be idle, with the queue lock held, before it freed a size's images and
feature (free_size: a third scene size, a DLSS model change; stale_sizes), the features release_kept releases before a
create, a reactive-mask snapshot (free_snap), the HUD-less picture of another size (hudless_from), prebloom's images
(pb_images) and what retire_idle gives back. Those run on the thread that replays the game's submissions, and with the
queue lock held the present's flip, the completion thread's hold release and every upload submit waited as well, at
each pause-screen size, reload, window size, dynamic resolution step or mode change; AUDIT F-4-02 measured 16.4 to
44.9 ms for release_kept's wait together with the create that followed.
Now nothing there waits:
- images only the replay uses go to vk_memory.c's deferred destruction, stamped with the submission being recorded;
- a DLSS feature goes into a list (vk_up_defer.h) with the timeline value of the last submission that created or
evaluated it (Size.feat_fence) and is released on the same thread once the GPU is past that value, which is the
rule NGX gives for releasing a feature and keeps every NGX call on one thread;
- a size's motion vectors and the HUD-less picture, which frame generation reads at a present, go into that list
behind a present gate of 8 presents and only then to the deferred destruction; the device's idle never covered a
present still to come, so this covers more than the wait did;
- the list is taken at the top of every evaluation, at every pass while no evaluation runs (retire_idle), and where
something is let go of (vk_upscaler_let_go).
A feature creation refused while released features are still in flight waits on the timeline from the host for those
features (vk_upscaler_gpu_wait, no queue touched), takes them and tries once more, with a log line; a full list waits
for the device (vk_upscaler_settle). release_kept's features whose last frame the GPU still runs are now released after
the create that follows, not before it: none of them is evaluated again, and its log line says how many.
NX2NAVI_UPSCALER_IDLE_WAIT (upscaler_idle_wait in [display], default 0, read at start) set to 1 waits for the device
at each of these places as up to 0.10.1, for comparison: rows in cfg.c's known[], metas[] and sections[], and an entry
in nx2navi.ini.example. retire_idle still waits for the device before the FSR contexts it gives back while there are
any; vk_ffx.cpp and vk_ffx4.cpp follow in the next commit.
Not reproduced before the change: no DLSS session log here (C-6 rests on the code and on F-4-02's mark). Every runtime
file compiles, vk_dlaa.c and cfg.c with -Wall -Wextra and no warning; cfg_rows_check reports 0 problems.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
runtime/vk_up_defer.h holds, per owner, a DLSS feature, an FSR context or an image frame generation can still read,
each with the timeline value of the last submission that named it and an optional present count. upd_take releases,
in the order they were put, the entries whose value the GPU has reached and whose present count has passed; nothing
in it waits or locks, so the owner checks at its own calls on the thread that makes its other upscaler calls. It is
the piece the next commits use to take the upscalers' device-idle waits off the replaying thread: those waits stall
the present's flip, the completion thread and upload submits with the queue lock held, at every pause-screen size,
reload, window size or mode change. Nothing uses it yet.
tests/up_defer_check.py builds tests/up_defer_check.c with the header as C and as C++ (vk_ffx.cpp and vk_ffx4.cpp
include it) with -Wall -Wextra -Werror and checks both gates, the order, a full list, a null handle and a release that
puts. It passed, and failed with each of three faults put into a copy of the header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_dlaa.c pb_images noted the present count in PB.used and then, for a new picture size, pb_destroy cleared the whole
struct with the old images, PB.used with it. The new images were made with PB.used 0, so at the next present
idle_release took them for 600 presents unused (once the run was 600 presents old) and destroyed them without a wait
for the GPU, while the frame that had just drawn into them could still be in flight; the frame after made them again.
With dlss_prebloom = 1 (off by default) that happened at every change of the picture's size, with the log line
"prebloom: the picture-size images went unused for 600 presents: freed" right after it. PB.used is now set again once
the new images are made.
Not reproduced before the change (found by reading the code). Every runtime file compiles, vk_dlaa.c with -Wall
-Wextra and no warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The review found that memory.c mem_lazy_skip's loop over the ranges armed for another buffer waited for a free entry
in the flight table before it looked for one. On its last pass, which finds nothing left to take, the replay could
still wait for another thread's fetch to end when the table was full. Now it looks first, and waits for room only when
it has a range to take and every entry is in use, then looks again. The search is lazy_foreign_locked, shared with the
check before the loop. Not reproduced before the change; lazy_fetch_check passed in all its scenarios (foreign and
flood among them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that vk_memory.c mirror_evict picks its victims before it fetches their armed ranges without
pool_lock, and skips a victim vk_pool_fini took off the mirrored list meanwhile. The bytes freed then fell short of
what the caller needed, and the candidates after the victims were never tried, where the loop under one lock had moved
on to the next. Now every candidate's range and mirror is kept, and the loop under the lock goes on through the
candidates until the bytes are freed; a candidate past the first victims has its armed ranges fetched under the lock
there, as every victim's were in 0.10.1. hot_check demotes every pool that qualifies, up to eight, so it has nothing
to make up: its comment says that a candidate finalized meanwhile is not replaced, since its mirror goes with the pool.
Rare, needing a finalize between the two locks; compile check only, as an eviction needs a device. Not reproduced
before the change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that the comments on frz_lock (memory.c, above FRZ_SHIFT), lazy_fly_run and vk_memory.c lazy_fetch
said a fetch runs under frz_lock only in a mapping change's thaw or with NX2NAVI_WB_LAZY_UNLOCKED=0. In fact
thaw_begin_locked's lazy_drop_region_locked runs in every thaw under the lock: a mapping change, the lazy heap's commit,
and thaw_fault on a write lazy_fault did not take. lazy_drop_region_locked's own comment said "a write fault in a
frozen region", but a frozen region holds no armed ranges. And the LZ_SLOTS comment called four slots more than the
threads that touch armed pages together, where the reference session had six such threads and the flight table holds
64. The comments now give that list of thaws, and LZ_SLOTS says that fetches past it wait 20 us at a time for a slot.
Comments only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found two windows in memory.c lazy_fault, where a fault holding frz_lock and lz_lock finds its address
neither armed nor in flight. It returned 0 there, and access_fault then asked for the page's state without a lock.
A write whose range a flight gave back while the write waited for frz_lock went on to thaw_fault, whose thaw released
every armed range of the 2 MiB region (lazy_drop_region_locked), each fetched under the lock. And after a release
that sets no hold (mem_lazy_skip's give-back of another buffer's range), the completion thread could arm the page
again between that 0 and access_fault's query, so a read saw an inaccessible page and went to the crash handler; that
one predates the ranges in flight.
Now a fault that finds a flight there waits for it as before. Otherwise it releases lz_lock, asks for the page's state
while it still holds frz_lock, under which no arm runs, and runs the access again when the page allows it: a read on
a readable page, a write on a read-write one. A write to a frozen page still goes on to thaw_fault, and an address
that is not ours to the crash handler.
The new late_write scenario of tests/lazy_fetch_check.py has a thread write a range whose release starts and ends
while the write waits for frz_lock: the write runs again and the region's other range stays armed. With the old
return of 0 it fails, the other range released. The read's window has no check: the arm would have to land between
two instructions. Not reproduced in the game before the change; lazy_fetch_check passed in all its scenarios.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the setting at 0, frz_lock was held across every fault's and release's fetch again and mem_thaw released under the
lock, but two parts of the new code stayed. vk_memory.c mirror_evict and hot_check still fetched their victims'
armed ranges before taking pool_lock again, and a release still put up to 16 copies in one submission. Those are the
parts that run only with a device and have no check without one, so a device loss caused by them was the one case the
switch could not take back. Now vk_memory.c reads the setting too (evict_unlocked, once on the replay thread, where
both run): at 0 neither drops pool_lock, and the release under it fetches everything, victim by victim, as before. And
memory.c lazy_take_batch_locked takes one segment at a time at 0, so each is fetched on its own.
What stays at 0 is the table of ranges in flight: a touch of a range being fetched waits for that fetch and runs
again (a write no longer goes on to thaw_fault and releases its region), the hold is set as the range goes out, and
mem_lazy_touches counts the range. memory.c "Ranges in flight", nx2navi.ini.example and docs/building.md say so.
tests/lazy_fetch_check.py gains locked_release, the release scenario at 0: three fetches of one copy each, where the
batched release makes two of up to two copies. It fails with the cap taken out. The eviction's side has a compile
check only. Not reproduced before the change; lazy_fetch_check passed in all its scenarios.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
memory.c mem_thaw calls lazy_release_regions before it takes frz_lock, so the copy a file read's thaw needs (the
buffer's armed ranges with stale bytes) no longer holds up the other threads' faults, freezes, thaws and arms. All 20
scenarios of tests/lazy_fetch_check.py passed with that call taken out: the thaw scenario reaches the same end state
through thaw_begin_locked's lazy_drop_region_locked under the lock. The new thaw_unlocked scenario has a thread thaw a
page of a range armed with stale bytes while the fetch takes 300 ms, and freezes another region meanwhile: the freeze
returns at once, and took 300 ms with the call taken out. locked_thaw runs it with NX2NAVI_WB_LAZY_UNLOCKED=0, where
the freeze waits for the fetch as in 0.10.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
memory.c mem_lazy_arm marks the region it protects in lz_arm_r, so that lazy_flags_locked keeps lzr set when a
release's flight in the same region ends during the arm's clean test. Without that mark the flight's end cleared lzr,
lazy_fault let a read of the arming range go, and access_fault passed it to the crash handler. All 20 scenarios of
tests/lazy_fetch_check.py passed with the mark taken out. The new arm_flight scenario has a release fetch one range for
100 ms, an arm of another range of the region wait 400 ms in its clean test, and a read of that range fault after the
flight ended: it passes, and exits with an access violation when lazy_flags_locked ignores lz_arm_r.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A fetch of lazily written pages runs inside the fault handler under frz_lock (memory.c lazy_gone_locked and lazy_piece_fetch_locked call vk_memory.c lazy_fetch, a GPU copy after everything handed over and a wait for it), so every other fault, thaw, freeze and arm waits for the whole copy. The line gave only the fetches' sum: the 0.10.0 session's 848 fetches took 415.8 ms in all, and how long one took, or how long another thread waited behind it, was not known. Both fetch sites now keep the longest fetch, and lazy_fault keeps the longest time a fault waited to take frz_lock; the report line prints them as "the longest X ms, faults waited up to Y ms for the freeze lock" and starts both over. No tool parses the line.
tests\lazy_fetch_check.py's new report scenario fetches for 30 and 120 ms and has a third fault wait 60 ms for frz_lock: the line reads 120.29 ms and 60.03 ms, and the next one 0.00 for both. arm_race, arm_race_slow, wb_lazy_check and heap_lazy_copy_check passed; memory.c compiles without a warning under -Wall -Wextra.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mem_lazy_arm made a range's pages inaccessible (lazy_protect_locked: the protect, then the back end's clean test) and set the region's lzr mark only after the segment was in the list. lazy_fault returns 0 at once for an unmarked region, without taking frz_lock, so a CPU read in that window found the page inaccessible in access_fault and went to the crash handler as an access violation. A write survived only because thaw_fault waits for frz_lock. Arms come from the completion thread (vk_memory.c vk_gpu_written_back) when a submission's fence completes, which is when the game's own fence wait returns and it reads the results; the window lasts as long as the protect and the clean test, which can take tens of milliseconds while the driver pins memory.
The mark is now set before the protect, after the cheap refusals, and put back when the protect or the clean test refuses the arm. A fault from the protect on finds the mark, waits for frz_lock and then finds the segment, or the pages given back. Every store to lzr is made under frz_lock, which the arm holds, so putting it back is exact.
Not reproduced in the game before the change. The new tests\lazy_fetch_check.py reproduces it with the real Windows fault handler: a thread reads a page while it is armed and given back 20,000 times (arm_race: an access violation in 5 of 6 runs before the change), and once with a clean test of 100 ms (arm_race_slow: an access violation in every run). Both pass in every run since. wb_lazy_check, heap_lazy_copy_check, freeze_hold_check and mem_growth_check passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether the 64-bit generations hold past 2^32 in the game, with every texture check, mirror sync and shadow copy of a
real scene, could not be seen in play: reaching 2^32 takes 15 to 66 hours. gen_wrap_check shows it for the generation
code alone. NX2NAVI_GEN_SEED=<n> (nx2navi.ini gen_seed, diagnostics, default 0) makes the run's first generation
n + 1: with 4294966000 a run passes 2^32 within its first seconds, and with 1099511627000 the low 32 bits pass
through 0 again.
The first gen_next, before anything is stamped, reads the value (gen_seed). It is parsed with strtoull, decimal or
0x hexadecimal: strtoul stops at 2^32 - 1 where unsigned long is 32 bits, as on Windows, and would turn a seed past
32 bits into 4294967295 without a word. It is capped at 2^62, far from the 64-bit end, and only ever raises the
counter, since a lower value would leave stamps newer than it that read as written at every check. A value that is
no number, or negative, is logged and ignored; a seed in use is logged as "mem: page generations start at <n>
(NX2NAVI_GEN_SEED, a test switch)". Without the switch the only cost is one compare a generation that is true once.
cfg.c gets its metas row (0, 0..4611686018427387904, read at start), its known[] name and its sections[] entry, each
on a line of its own next to the other memory test switches (STAGING_FAIL_AT, VRAM_BALLAST_MB); nx2navi.ini.example
gets a commented gen_seed entry under [diagnostics]. The settings menu does not show it.
tests/gen_wrap_check runs the generation code a second way: the counter left at 0 and the seed taken from
NX2NAVI_GEN_SEED, for 4294967288, 0xfffffffff8, 0x7ffffffffffffff8 (capped), 12abc, -8, 0 and unset, each with the
start and the log line expected of it, then the same compares across the boundary. With strtoul in place of strtoull
two of those runs fail. Checks that passed: gen_wrap_check, gen_width_check, cfg_rows_check (0 problems),
pass_cc_check on vk_memory.c and cfg.c, no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tables of page write generations take 8 bytes a page since the generations became 64-bit: each 256 MiB chunk of
guest address space stamped is 512 KiB a table instead of 256 KiB, and reg_max and creg_max are 2 MiB each. The
estimate for an outdoor area was 27 MiB of chunks at most, with less resident, but nothing in a log said how many
chunks a session allocates. slot_in now counts them (under gen_lock, an atomic add read without the lock), and
vk_mem_sizes adds "page generation table chunks N, X MiB" after "page generations handed out": the chunks of both
tables and the two per-region arrays, as allocated (only the pages that hold stamps become resident).
Only trace=perf logs the line; slot_in allocates a chunk a few dozen times a session. Checks that passed:
pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings, gen_wrap_check, gen_width_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_memory.c handed out page write generations from a 32-bit counter (gen_now) and every consumer compared them as
32-bit values, with 0 meaning "never stamped". At 124 to 336 generations a frame (a 0.10.0 log with trace = perf) the
counter wrapped after about 66 h of play at 60 fps, 27.6 h at 144 and 15 to 17 h at 240. Past the wrap a stamp made
before it compared newer than every later check and one made after it older: textures and VRAM mirrors missed the
CPU's writes (stale textures, and the lazy fetch copying older mirror bytes over a CPU write), pages nothing wrote read
as written at every check (a hash or upload every time, and every such page of a mirrored pool copied up at each
sync), and the generation handed out at the wrap was 0. At 64 bits nothing wraps: 2^64 lasts millions of years at
336 a frame and 240 fps. Below 2^32 every value and every compare is the same as before.
What changed: gen_now, both tables (gen_chunk, cgen_chunk), reg_max and creg_max, PoolRec's synced_gen, mgen and sgen,
dirty_gen and DIRTY_UNSYNCED, VSample.mgen, every local and parameter that holds a generation in vk_memory.c,
vk_texture.c (Tex.wgen and gpu_gen, PEnt.since, storage_rewritten and its callers, sweep_hard's write-back check
included) and tex_probe.c, and vk.h's vk_mem_changed, vk_mem_changed_snap and vk_mem_unchanged(_why)_locked. The verify
mode's mismatch line prints the generations with %llu. The readers that take no gen_lock (lazy_clean, cpu_wrote_since,
verify_cb, vk_mem_sizes) stay lock-free at 64 bits: the slots, mgen, gen_now and a new chunk pointer are now stored
atomically (relaxed, or release where a reader acquires), the same instructions on x86-64, and a static assertion
holds that 64-bit atomics are lock-free. No lock, wait or thread changes.
gen_next, which hands out every generation, no longer warns at 0xC0000000 and up or at the wrap, which cannot come;
it logs one line when the count reaches 2^32, where earlier versions ran out, with the frame, the hours and the rate.
Cost: the tables take 8 bytes a page instead of 4 (512 KiB more per 256 MiB of guest address space stamped, both
tables together, allocated as before when a chunk is first stamped), reg_max and creg_max 2 MiB each instead of 1,
Tex and PoolRec 8 bytes more, PEnt 8 more. The page walks read 8 bytes a slot; a micro-benchmark of the walk measured no
difference.
Not reproduced in the game before the change: the wrong answers were reproduced by compiling vk_memory.c's generation
code with the counter seeded below 2^32. tests/gen_wrap_check now runs that code, with the readers outside its
section, across 2^32, 2^40 and 2^63 and checks that the compares answer as below the boundary; on the code before the
change 19 of its checks fail at 2^32 and the other two seeds do not fit the counter. The new tests/gen_width_check
compiles vk_memory.c, vk_texture.c and tex_probe.c with -Wshorten-64-to-32, -Wformat and -Wincompatible-pointer-types
as errors, so a 32-bit local left behind is found (shown by reverting single locals, fields and formats). Checks that
passed: gen_wrap_check, gen_width_check, pass_cc_check of every runtime file, no new -Wall -Wextra warnings in the
three files, tex_release_check, tex_compact_check, wb_lazy_check, gpa_check, mem_growth_check, mem_budget_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found the comment incomplete: it named nx2sl_available on the device's thread and nx2sl_reflex2 under the
runtime's present lock, but nx2sl_reflex2 also runs on the device's thread with no lock (vk_dlssg.c
vk_dlssg_device_ready, reflex_apply(0)), and an older runtime reaches it through nx2sl_reflex. The comment now says
the runtime calls them on the device's thread before any present or under its present lock, never two at once; the
conclusion is unchanged. Comment only: the DLL builds with MSVC against the pinned Streamline SDK and passes the
export check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that a play-test item expected nx2_sl.cpp nx2sl_reflex2's line after each resize, fullscreen toggle
or HDR change, but that export logs only its first 8 calls, which the device, the first swapchain, the first
hand-over and a movie use up, so a later rebuild usually logged nothing and the tester would read that as a failure.
vk_dlssg.c vk_dlssg_create_swapchain now logs, for each of the first 16 swapchains with a DLL that has nx2sl_reflex2,
the limit it sent and the one Streamline took ("dlssg: swapchain N: Reflex's mode and frame limit sent again"), on
the present thread under the present and queue locks where reflex_apply already logs a refused limit. The compile
check and -Wall on vk_dlssg.c (no new warnings) pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that the comment said an overflow in the period arithmetic fails the check, but -fsanitize=undefined
does not trap unsigned wrap-around, and every period and time in runtime/dlssg_pace.h is a uint64_t. The check now
builds with -fsanitize=undefined,unsigned-integer-overflow and the same -fsanitize-trap list, and the comment says
why. It passes; a uint64_t subtraction that wraps traps under these flags and runs on under the old ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that vk_dlssg.c vk_dlssg_frame_start timed every held sleep as PERF_PACE, though the sleep takes the
pacer's place only while Streamline holds a frame limit. With reflex_limit 0, a DLL older than nx2sl_reflex2, during a
movie, after a refused limit or once the frames showed that Reflex does not hold the limit, the sleep is the driver's
wait for the GPU while the pacer waits as well, so GPU-bound frames read "bound: pace" in the perf windows (the
misattribution P-7 and F-7-04 describe), and reflex_limit 0 did not give back the old attribution. The sleep is now
timed only when it holds the frame (as before) and limit_set is not 0; the statistics line's held frame starts and
their time are counted as they were. Not reproduced before the change: the compile check and -Wall on vk_dlssg.c (no
new warnings), dlssg_pace_check and the test matrix pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that nothing on the release path ran the new export check: tools/make_launcher.py build_app and
tools/make_kit.py copied whatever --dlssg DLL they were given, and the release script still named the DLL of 0.8.0,
which lacks nx2sl_reflex2. Shipped that way, Reflex would never hold the frame limit while players still got this
branch's other changes. make_launcher.py check_dlssg and make_kit.py main now run build_dlssg.check on the --dlssg DLL
beside the translator's check, before anything is resolved, downloaded or deleted; build_dlssg.check also stops on a
missing file and on a file that is no DLL, with a message instead of a traceback. tests/dlssg_exports_check.py adds a
DLL with every declared export (it passes), the two-export DLL, a missing file and two files that are no DLL (they
stop, naming why), make_launcher.check_dlssg on both DLLs, and that both scripts' main makes the check before
streamline_pin.resolve and shutil.rmtree; it fails when the call is taken out of make_launcher.py main. The DLL built
from this branch passes make_launcher.check_dlssg; the DLL of 0.8.0 is refused, naming nx2sl_reflex2.
docs/launcher.md and docs/building.md "Checks" say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime takes a missing optional export (nx2sl_reflex2, nx2sl_frames_max) for an older DLL and leaves the
feature out with one log line, so a release that ships a DLL built from older sources than its runtime loses Reflex's
frame limit without an error; the release script took the DLL of 0.8.0 from a fixed path. After the build,
build_dlssg.py now reads every PFN_nx2sl_<name> in runtime/dlssg/nx2_sl.h and the DLL's export directory (a small
PE reader of its own, PE32 and PE32+) and exits naming each export that is missing; --check <dll> makes the same
check without building. tests/dlssg_exports_check.py builds a DLL with two of three declared exports with MSYS2's
clang and checks that the third is named, and that nx2_sl.h's own declarations are read in full; docs/building.md
"Checks" and docs/launcher.md name both. The DLL built from this branch with the pinned Streamline 2.14.16 SDK passes
(15 exports); the one built before nx2sl_reflex2 fails, naming it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reflex's hold of the frame limit with DLSS-G is unmeasured here (no RTX 40 or 50), so a player needs a way back to
the pacing of 0.10.1 without an older DLL. vk_dlssg.c limit_allowed reads the setting through cfg_bool, again
whenever a setting changed (cfg_version), as vk_lowlat.c reads low_latency; with 0 the presenting guest thread asks
Reflex for no limit, the present thread hands Streamline 0, and the "frame rate:" line names NX2NAVI_REFLEX_LIMIT=0 as
the reason the game's own limiter holds it. The row sits beside FG_MULT in metas[], beside REFLEX in known[] and
beside LOW_LATENCY in sections[] (display), and nx2navi.ini.example explains it under framegen_multiplier.
cfg_rows_check reports 0 problems; vk_dlssg.c and cfg.c compile clean with the dev build's flags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With DLSS-G, Reflex's sleep held each frame's start with no limit while the game's pacer still held the limit on the
present thread and at the window image's acquire (A-16, P-16): the pacer's wait came after the picture was drawn, so
the pad was read up to a period before the picture left, on top of Reflex's own wait for the GPU.
vk_dlssg.c vk_dlssg_pace, on the presenting guest thread in nvnQueuePresentTexture, asks dlssg_pace.h which limit Reflex
should hold and stores it; vk_dlssg_frame on the present thread hands a changed limit to Streamline before its present
(reflex_apply, under the present lock, which keeps Streamline's options calls on one thread at a time), as does each
new swapchain, which has neither mode nor limit. GfxBackend.frame_paced now returns the period the pacer still holds:
vk_backend.c vk_be_frame_paced gives the game's ceiling (frame_step_ceiling_ns, 240) while Reflex holds the limit,
0 where a driver's low latency holds it (vk_lowlat_pace, unchanged), else the period; during a movie the back ends get
0 and the pacer keeps the movie's period and catch-up slots as before. The pacer takes the limit back before the first
host present, after 200 ms without one, with a DLL older than nx2sl_reflex2 (one log line at start), for a limit
Streamline refused, and for the run when the frames show Reflex does not hold it; the "frame rate:" lines name who
holds it, the "dlssg: present N" line counts the frame starts Reflex held, and Reflex's sleep now counts as pacing
(perf_end_any, on whichever thread acquires). No wait is added on a drawing thread: the limiter's wait moves from the
acquire's sleep_until and the present thread's slot to Reflex's sleep a few lines later in the same acquire, and the
present thread's options call returns at once. docs/architecture.md names Reflex as a limiter. Not reproduced before
the change (no RTX 40 or 50 here); vk_dlssg.c, vk_backend.c and nvn_api_obj.c compile clean with the dev build's
flags, and dlssg_pace_check, fg_reset_check and frame_step_check pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Reflex can hold the frame limit where each frame starts (nx2sl_reflex2), but the driver only takes a limit through
Streamline, the SDK does not say whether its minimum interval counts the frames the game draws or every frame on
screen, and nothing tells the game that the limit holds. dlssg_pace_step decides from what the presenting guest
thread sees alone: the limit Reflex should be given (the drawn period, 0 during a movie, before the first host
present, after 200 ms without one, or when not allowed), and whether Reflex holds this frame's limit, which it does
only once Streamline took exactly that limit. Over windows of 60 presents Reflex held to one period and multiplier,
over half sooner than three quarters of the period means nothing holds the game and the pacer takes the limit for
the run; over half the multiplier periods apart means the driver counted the frames on screen, and Reflex is given
the period over the multiplier from then on, or the pacer the limit if it happens again. Being pure, the decision is
checked without a GPU: tests/dlssg_pace_check.py builds it with undefined-behaviour traps and drives it through
each case; four mutations of the header (the window's majority, the idle bound, a host present stamped after the
clock read, the applied-limit test) each fail it. docs/building.md "Checks" names it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With DLSS Frame Generation, nx2sl_reflex set only Reflex's mode, so Reflex's sleep at the frame's start held no
limit and the game's own pacer held the finished picture until its turn, a second wait after the frame was drawn
(A-16, P-16). nx2sl_reflex2(mode, frame_limit_us) fills sl::ReflexOptions::frameLimitUs as well, which the driver's
sleep then holds as its minimum interval. Streamline drops both with the swapchain and a call without the limit
clears it, so every call carries both. It returns 1 only when Streamline took them and, for a limit, did not report
Reflex's sleep as unavailable on this GPU and driver (slReflexGetState, asked from nx2sl_available on the device's
thread and from nx2sl_reflex2 under the runtime's present lock, never at once). nx2sl_reflex stays exported for older
runtimes and sets the mode alone; NX2SL_VERSION stays 3, since the runtime refuses any other version and the new
export is optional. Built with tools/build_dlssg.py against the pinned Streamline 2.14.16 SDK: clean at /W3, 15
exports including nx2sl_reflex2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that docs/architecture.md said the ends of a crash and a fatal error make no call into RTSS's library,
while rtss_limit.c rtss_limit_crash puts the profile back as an orderly exit does where the state file could not be
written. The text now says that, that it never runs from RTSS's own thread, that the line takes each later value
before the profile does, and that a state file that cannot be read is left with the other executables' lines. Its
"one limiter" and README.md claims were corrected with the movie and unproven-hold fixes, and nx2navi.ini.example with
the kept-out restore. docs/building.md's description of tests/rtss_limit_check.py now names the checks added since:
the previous written value, a kill inside a save, put backs that do not land, an unreadable state file and a crash on
RTSS's own thread.
No behaviour changes. Checks that passed: tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found three comments that claimed more than the code does. rtss_decide.h rtss_pace_period and frame_rate.c
(above frame_step_cap_ns) said the frames RTSS lets through always come further apart than the 240 ceiling, so the two
limiters never stack: at a limit of 240, or unlimited, which RTSS gets as 240, the ceiling is RTSS's own period, and
both may hold a frame. rtss_limit.c state_put said it runs on the worker thread only, but rtss_limit_restore's put back
calls it on the exiting thread, under the lock. And rtss_limit.c's header said a crash and a fatal error make no call
into RTSS's library, which holds only while the state file has the run's line. The comments now say so, and the header
names the exception at 240. (The comment above rtss_decide was rewrapped and lost its reference to rtss_fresh with the
unproven-hold rule.)
No behaviour changes. Checks that passed: tests/rtss_limit_check.py, tests/frame_step_check.py, pass_cc_check.py --wall
on rtss_limit.c and frame_rate.c (only frame_rate.c's existing game_interval warning), tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that no test ran rtss_limit_crash's thread gate: where the state file holds no line of the run,
rtss_limit_crash puts the profile back through rtss_limit_restore, except on the worker's own thread, which may hold
the lock that restore tries for a second; only the static check in tests/rtss_limit_check.py looked at it.
tests/rtss_worker_check.c crash_worker_thread now makes the state file unwritable and calls rtss_limit_crash from inside
SaveProfile on the worker thread, with the lock held: it must return within 200 ms with no call into the fake hooks
library (a new count of every call), close the run, and let no later want reach the profile. With the gate removed
from a copy of rtss_limit.c, the scenario fails.
Checks that passed: tests/rtss_limit_check.py, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that rtss_limit.c state_put took a state file plat_file_read could not read for a missing one: a file
another program held open (a scan) was rewritten with this profile's line alone, or deleted when the run removed its
line, and the lines of other executables that share the folder (dev or probe builds) were lost. state_put now returns
0 when the read gave nothing but plat_file_size finds the file with bytes in it; the run then goes on as with a file
that cannot be written (one log line, and a crash puts the profile back as an orderly exit does). An empty file is
still rewritten. tests/rtss_worker_check.c state_locked checks a file holding another executable's line that does not
read: the run limits as before, logs it, and the file stays as it was through the exit.
Not reproduced before the change. Checks that passed: tests/rtss_limit_check.py, pass_cc_check.py --wall on
rtss_limit.c, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
The review found that the worker read rtss-profile.txt only on its first poll with the hooks library, which it loads
only for a limit, so a start with frame generation on, with the driver's low latency on from the first present, or
with NX2NAVI_RTSS=0 never put back a killed run's limit: RTSS then went on limiting the game at that value (counting
the generated frames too under frame generation), and the log and the menu reported it as the player's own limit
beside the game's limiter. rtss_limit.c worker now looks at the state file on its first poll that finds RTSS running
(left_start): a limit puts the profile back with the library it loads anyway, as before; frame generation and low
latency load the library for that one restore (restore_left, which now takes the line found); NX2NAVI_RTSS=0 keeps the
thread read-only and logs that the file holds the game's limit from a run that did not close and that a start with
RTSS let in puts it back. A restore the library could not be loaded for waits for the first poll with the library.
tests/rtss_worker_check.c adds kill_restart_framegen (the next start asks frame generation first: the profile is
removed, no limit written, the line gone) and kill_restart_setting (NX2NAVI_RTSS=0: no library loaded, the profile and
the line stay, the log says so). The rtss_limit.c header, docs/architecture.md, docs/building.md and
nx2navi.ini.example say so, and the ini text no longer says the game alone paces movies under RTSS.
Not reproduced before the change. Checks that passed: tests/rtss_limit_check.py (40 of 40),
pass_cc_check.py --wall on rtss_limit.c, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that rtss_decide.h rtss_target writes no limit only while a movie runs faster than the play limit, so
at the default limit of 60 a 60 fps movie leaves 60 in RTSS's profile and RTSS holds the movie beside the game's pacer.
Its comment said RTSS at the movie's rate would space out the frames that catch up after a late frame, and README.md
("one limiter at a time") and docs/architecture.md read as if every movie under RTSS were the pacer's alone. The
approved rule writes nothing to the profile at movies when the limit is 60 or more, so the code stays: rtss_target's
and rtss_pace_period's comments now say that at the movie's own rate RTSS keeps holding it and that whether its
limiter lets the frames that catch up through is not measured, README.md drops "one limiter at a time", and
docs/architecture.md lists the movie at 60 among the exceptions to one limiter. tests/rtss_limit_check.c pins a movie
at 60 under a limit of 60: no write, the profile keeps 60, held again after it.
No behaviour changes. Checks that passed: tests/rtss_limit_check.py, tools/check_content.py --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>