1326 Commits

Author SHA1 Message Date
chrg6
ddc0247b25 Count the draws skipped for a pending pipeline with atomics, as the replay adds to the count on one thread while the present takes it on another.
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>
2026-11-13 20:13:02 -05:00
chrg6
c56a87872e Work out the frame hold's allowance in vk_frame_hold_left without writing it, so that frame_hold at the present is its only writer.
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>
2026-11-13 20:13:02 -05:00
chrg6
7a1c220e06 Test held work against the event's value now when another thread asks without the submission lock, and read the held count and the submission count as atomics there.
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>
2026-11-13 20:13:02 -05:00
chrg6
cde143c5d9 Say what upscaler_idle_wait = 1 brings back and what it does not.
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>
2026-11-13 20:13:02 -05:00
chrg6
75b06b0a30 Count only release_kept's own features in its line of those released once the GPU is past them.
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>
2026-11-13 20:13:02 -05:00
chrg6
bede842db7 Reword the comment on pb_images' last PB.used, which still described the clear 9071dfc3 removed.
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>
2026-11-13 20:13:02 -05:00
chrg6
74bedbee73 Name the upscalers' wait mark "upscaler GPU waits", since it also times the timeline wait after a refused creation.
The review of 9071dfc3 found that PERF_M_UPWAIT, printed as "upscaler device waits", times two waits: vk_dlaa.c
vk_upscaler_device_idle's wait for the device, and vk_upscaler_gpu_wait's host wait on the timeline after a refused
DLSS or FSR creation, which touches no queue. host.h tied the mark to the first alone, and a play-test that expects no
"upscaler device waits" would have read the second, at a refused creation on a card short of video memory, as a wait
for the device. perf.c now names the mark "upscaler GPU waits", and host.h, vk_ffx.h and vk_dlaa.c say that both waits
are in it. What makes it a wait for the device still shows in the log: the rollback switch in the "cfg: ... sets:"
line, a full list ("a wait for the device") and a failed timeline wait ("waiting for the device instead") in lines of
their own.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
4506988335 Make upscaler_idle_check fail where a place that lets something go frees it at once.
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>
2026-11-13 20:13:02 -05:00
chrg6
61f08a177d Try an FSR 3.1 context creation once more after the GPU is past the contexts let go of, instead of failing the run.
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>
2026-11-13 20:13:02 -05:00
chrg6
ed87d30628 Check from the sources that no upscaler waits for the device where it lets something go.
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>
2026-11-13 20:13:01 -05:00
chrg6
9065d1485f Let FSR 3.1 and FSR 4 destroy a context they let go of once the GPU is past its last upscale, instead of waiting for the device.
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>
2026-11-13 20:13:01 -05:00
chrg6
22c86f08a0 Let DLSS free what it lets go of once the GPU is past it, instead of waiting for the device on the replaying thread.
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>
2026-11-13 20:13:01 -05:00
chrg6
86e6d62d35 Add a list of what an upscaler lets go of, released once the GPU is past it, and its unit check.
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>
2026-11-13 20:13:01 -05:00
chrg6
c14988150c Keep the prebloom images' last use when they are made again for another picture size.
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>
2026-11-13 20:12:51 -05:00
chrg6
c6b29834eb Give the upscalers' device-idle waits and their releases perf marks of their own, so a slow frame's line names them.
The twelve places where DLSS (vk_dlaa.c) and FSR 3.1 and FSR 4 (vk_ffx.cpp, vk_ffx4.cpp context_for) wait for the
whole device to be idle before they free what the GPU may still use go through one function, vk_upscaler_device_idle,
which NX2NAVI_TRACE=perf times as "upscaler device waits". The releases of DLSS features and of FSR contexts are timed
as "upscaler releases". These waits run on the replaying thread with the queue lock held, so the present's flip, the
completion thread's hold release and upload submits wait with them at each pause-screen size, reload, window size or
mode change; until now only the "DLSS feature creates" mark saw one of them, the wait in release_kept, which it summed
with the create. That mark now times the create alone.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
61b8a6d62c Wait for room in the flight table in mem_lazy_skip only when there is another buffer's range to give back.
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>
2026-11-13 20:12:50 -05:00
chrg6
47078fa0cd Have an eviction take the next candidate when a victim was finalized while pool_lock was down, as it did before.
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>
2026-11-13 20:12:50 -05:00
chrg6
b5e4243cc4 Name every thaw that fetches under the freeze lock in the lazy write-back's comments, and say what LZ_SLOTS does when it runs short.
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>
2026-11-13 20:12:50 -05:00
chrg6
a8d18ef474 Ask an armed region's page state under the freeze lock when a fault finds its range gone, so that a late write is not taken for a frozen page's and a read is not passed on past an arm.
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>
2026-11-13 20:12:50 -05:00
chrg6
da60e0d327 Make wb_lazy_unlocked = 0 bring back 0.10.1's locking in evictions and releases as well, which the review found it did not.
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>
2026-11-13 20:12:50 -05:00
chrg6
0eee849948 Check that a thaw fetches the armed ranges of its regions without the freeze lock, which the review found untested.
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>
2026-11-13 20:12:50 -05:00
chrg6
92121a11e8 Check that an arm keeps its region marked while a range in flight there ends, which the review found untested.
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>
2026-11-13 20:12:50 -05:00
chrg6
2e077edc9d Fetch the lazy write-back's ranges without the freeze lock, so a touch of GPU-written memory holds up only the threads that touch that range, with wb_lazy_unlocked = 0 to go back.
A CPU touch of an armed range ran its fetch (vk_memory.c lazy_fetch: a GPU copy after everything handed over, and a wait of up to 10 s) inside the fault handler under frz_lock. Every other fault, thaw, freeze and arm waited for the whole copy meanwhile: the replay's copies of guest memory (mem_copy_readable, vk_memory.c mirror_capture) and its freeze pass, the completion thread's arms, file reads and pool finalizes. A write that lost the race to the fetch went to thaw_fault after it and released every other armed range of its 2 MiB region, a second fetch. mirror_evict and hot_check fetched their victims' ranges under the exclusive pool_lock, which the completion thread's arms and every pool lookup wait for. This is AUDIT F-4-05 (C-12); the design's probe of the unchanged code had an unrelated mem_copy_readable, arm and freeze each wait 299 ms behind a 300 ms fetch, and the losing write 378.8 ms with two fetches.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:50 -05:00
chrg6
f586c291fa Give the longest lazy fetch and the longest wait of a fault for the freeze lock on the "mem: lazy write-back" line.
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>
2026-11-13 20:12:50 -05:00
chrg6
ef0673fba0 Mark a region for the lazy write-back before its pages become inaccessible, so that a read during an arm is not passed to the crash handler.
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>
2026-11-13 20:12:50 -05:00
chrg6
5ee960f811 Add NX2NAVI_GEN_SEED, a test switch that starts the page write generations at a given value, so a play-test can cross 2^32 in seconds instead of 15 to 66 hours.
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>
2026-11-13 20:12:50 -05:00
chrg6
028a6ce128 Count the page generation tables in trace=perf's "mem: sizes" line, so a play-test shows what the 64-bit generations cost in host memory.
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>
2026-11-13 20:12:50 -05:00
chrg6
6b95c867c8 Make the page write generations 64-bit, so a long session no longer wraps them and every texture, mirror and shadow check after the wrap answers wrong.
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>
2026-11-13 20:12:50 -05:00
chrg6
879c76f91a Name every caller of reflex_state in nx2_sl.cpp's comment on why its calls never overlap.
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>
2026-11-13 20:12:50 -05:00
chrg6
bb0317ac08 Log Reflex's frame limit as each of the first 16 swapchains hands it to Streamline again.
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>
2026-11-13 20:12:50 -05:00
chrg6
358713fbf2 Trap unsigned wrap-around in tests/dlssg_pace_check.py, as its comment on overflows promised.
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>
2026-11-13 20:12:50 -05:00
chrg6
46bf4823e6 Count Reflex's sleep at the frame start as the pacer's wait only while Reflex holds a frame limit.
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>
2026-11-13 20:12:50 -05:00
chrg6
0131b1b263 Stop a launcher release and a build kit on an nx2navi_dlssg.dll that lacks an export runtime/dlssg/nx2_sl.h declares, before they download anything.
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>
2026-11-13 20:12:49 -05:00
chrg6
9f4f51f010 Fail tools/build_dlssg.py when nx2navi_dlssg.dll lacks an export nx2_sl.h declares, and let it check a DLL built earlier.
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>
2026-11-13 20:12:49 -05:00
chrg6
e0843439a7 Add the reflex_limit setting (NX2NAVI_REFLEX_LIMIT, default 1, live), which gives the frame limit back to the game's pacer with DLSS Frame Generation.
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>
2026-11-13 20:12:49 -05:00
chrg6
dc3f424fde Let Reflex hold the frame limit with DLSS Frame Generation, and the game's pacer keep only its ceiling while it does.
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>
2026-11-13 20:12:49 -05:00
chrg6
2bf32649af Name the game's frame-rate ceiling as a period of its own, frame_step_ceiling_ns, for a pacer that hands the limit over.
When Reflex holds the frame limit with DLSS Frame Generation, the game's pacer is to keep only the rate the game's
code survives (240 a second; the quick menu read address 0 at 640 to 700), so that a limiter that lets go, or one
that counts other frames than the game's, cannot run the game past it. frame_step_ceiling_ns gives that period
from LIMIT_FREE_MAX_HZ, or LIMIT_MAX_HZ for a build with the fps60 or realtime patch set; frame_step_cap_ns is left
as it is. tests/frame_step_check.c checks 4166666 ns for a game stepping by the clock; frame_step_check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
2d6f9f07b2 Decide who holds the frame limit with DLSS Frame Generation in a header of its own, runtime/dlssg_pace.h, with a check.
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>
2026-11-13 20:12:26 -05:00
chrg6
660c42f6d2 Give nx2navi_dlssg.dll an export that hands Reflex a frame limit along with its mode.
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>
2026-11-13 20:12:26 -05:00
chrg6
405a75f3b1 Say in docs/architecture.md when a crash still calls into RivaTuner Statistics Server, and list the new state-file checks in docs/building.md.
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>
2026-11-13 20:12:26 -05:00
chrg6
af2ecc3c03 Correct the comments on RivaTuner Statistics Server's pacing beside the ceiling, the state file's writers and the crash path.
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>
2026-11-13 20:12:26 -05:00
chrg6
c19f75e505 Check that a crash on RivaTuner Statistics Server's own thread, with no line in the state file, returns at once.
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>
2026-11-13 20:12:26 -05:00
chrg6
9f45d7448f Leave rtss-profile.txt alone when it is there but does not read, so the lines of other executables in the folder stay.
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>
2026-11-13 20:12:26 -05:00
chrg6
da822abec3 Keep the state file's line ahead of RivaTuner Statistics Server's profile, and keep what a run owes the profile after a put back that did not land.
The review found three ways rtss-profile.txt could fall behind the profile. Every write after a run's first one saved
the profile before the line, so a kill between the two left a line whose value the profile no longer held, and the
next start took the game's limit for the player's (rtss_restore_plan LEAVE). A put back whose write did not land kept
the line, but rtss_put_back_done cleared S.changed, so a later limit took the run's first-write path again and read the
game's own limit from the profile as the value to put back, overwriting the true one in the line. And put_back removed
the line after DeleteProfile without checking that the profile went away.

The line now carries the value the run wrote before the newest one (rtss_decide.h RtssSaved prev_limit and prev_den,
rtss1 with two more fields; the file came with this branch and was never released). rtss_limit.c apply writes the new
value into the line before the profile (record_before) and drops the previous one once the profile holds the new one,
or returns to it when RTSS refused it (record_after), and rtss_restore_plan takes a profile holding either value for
the game's. A new owed flag keeps had_profile, old_limit and old_denominator from the run's first write until a put
back lands: a later first write keeps them and the line's values, a failed first write leaves the line alone, and
rtss_limit_restore tries the put back again at the exit. put_back and restore_left check that a deleted profile is
gone before they remove the line, and restore_left's lines name the value the profile held. tests/rtss_limit_check.c
covers the new fields and the plan for either value; tests/rtss_worker_check.c checks the line before and after a
write (state_record), a run killed inside the save of its second limit and the next start removing the profile
(kill_restart_mid_write), a put back that did not land followed by a limit and the exit (put_back_owed), and a removal
that did not land (put_back_undeleted). Lines over 120 columns from the earlier fixes are wrapped.

Not reproduced before the change. Checks that passed: tests/rtss_limit_check.py (44 of 44), tests/exit_order_check.py,
tests/fatal_exit_check.py, pass_cc_check.py --wall on rtss_limit.c, tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
1744681f1a Give up on RivaTuner Statistics Server for the run when it hooks the game with the limit for 30 s and never shows it holds it.
The review found that nothing ended an RTSS hold that was never proven: when RTSS listed the game with the limit in
its profile but one sign never came (presents of another API value than Vulkan's, the update flag never cleared, a
period that never moved), the profile kept the limit and the game's pacer held the frames too for the whole run, two
limiters where the pacing rules allow one, with only a reason line in the log and nothing in the menu. Before the
hold had to be proven, RTSS alone held the game once it listed it. rtss_decide.h rtss_decide now counts the polls in a
row where RTSS lists the game, the profile holds a limit of this run (not "none"), a picture went out within 200 ms
and the hold is not shown; at RTSS_UNPROVEN_POLLS (60, 30 s) it puts the profile back and RTSS is out for the run
(RTSS_FAIL_UNPROVEN, "RivaTuner Statistics Server never showed it holds the game"). A minimised window, RTSS not
hooked, a want of no limit, a kept-out want or a poll that shows the hold start the count again. rtss_limit.c logs the
give-up, rtss_limit_state returns 3 for it, so the menu says RivaTuner does not limit the game and the game does, and
rtss_out_of_limit counts it, so a limit set in RTSS itself is reported as a second limiter. The comment above
rtss_decide, rewritten for the rule, is wrapped at 120 columns and no longer cites rtss_fresh, which does not exist.
tests/rtss_limit_check.c checks each sign that never comes (given up at the 60th poll, once, for the run), the polls
that do not count and a shown hold restarting the count; tests/rtss_worker_check.c unproven_run checks it against the
real worker. docs/architecture.md, docs/building.md and nx2navi.ini.example describe the rule and the remaining window
of two limiters before it.

This changes when the game takes the limit back from RTSS, beyond the approved rules: an RTSS whose hold never shows
in its shared memory (a version or setting that differs) is now left out after 30 s and the game's limiter holds
alone, as with rtss = 0, where it held the frames beside the game's limiter before. Not reproduced before the change.
Checks that passed: tests/rtss_limit_check.py (41 of 41), pass_cc_check.py --wall on rtss_limit.c,
tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
a7b4f0c7cb Put back a profile a run that did not close left in RivaTuner Statistics Server, also at a start that keeps RTSS out.
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>
2026-11-13 20:12:26 -05:00
chrg6
ad84e0a1c0 Say what RivaTuner Statistics Server does with a movie at the default limit of 60, where the profile keeps the limit.
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>
2026-11-13 20:12:26 -05:00
chrg6
80b96a748d Describe RivaTuner Statistics Server's hand-off, the pacer's fallbacks and rtss-profile.txt as they behave now.
docs/architecture.md said the pacer holds nothing while RTSS holds the limit, through movies and minimised windows,
that the pacer stands aside once RTSS lists the process, that RTSS fails at 1.2 times the limit over six polls, and
that a killed run leaves its limit for the next run to take as the player's. The one-limiter and RTSS paragraphs now
name the pacer's fallbacks (before the first host present, a movie, 200 ms with no host present, a limiter seen
letting frames through, any failure) and its 240 ceiling beside RTSS, the settle rule, a movie's profile, the proof
RTSS must give before the pacer stands aside, the 1.05 rule over three new periods, the rounding down, and the state
file with rtss_limit_crash. nx2navi.ini.example's rtss lines, README.md's frame generation item, docs/building.md's
rtss_limit_check and frame_step_check paragraphs and rtss_limit.c's header follow, and the comments at main.c
crash_handler's and dispatch.c fatal_end's rtss_limit_crash say when RTSS's library is still called there (no line in
the state file, never on RTSS's own thread). No code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
60694532a1 Round a fractional limit down for a RivaTuner Statistics Server without the denominator field, so "display" never holds the game above the refresh.
apply() rounded the millihertz to the nearest whole frame where RTSS has no FramerateLimitDenominator, so "display" on
a 59.94 Hz monitor wrote 60 and on a 143.856 Hz one 144: above the refresh, one frame more than the monitor shows
every 17 s and every 7 s. Only "display" asks a fractional rate under RTSS (fixed limits and movie rates are whole,
frame generation keeps RTSS out), so the new rtss_decide.h rtss_limit_value rounds every fraction down when the
denominator is missing (59/1, 143/1), and keeps a whole rate over 1 and a fraction over 1000 where RTSS knows the
denominator. VRR and present-wait handling stay to be measured.

Not reproduced before the change. rtss_limit_check (T15: rtss_limit_value at 59940, 60000, 143856, 0 and 240000 with
and without the denominator) and rtss_worker_check (round_logged_whole now 59/1 and 143/1, round_logged) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00
chrg6
4d4b97b043 Let a want reach RivaTuner Statistics Server's profile only once it stayed two polls, and write nothing before the first host present.
A want that flips between a limit and kept-out created and removed RTSS's profile at each flip: with a driver's low
latency vk_lowlat_on() is 0 before the first swapchain and during every swapchain rebuild, so a warm start without the
boot screen wrote the profile and removed it a poll later, and a poll that fell into a rebuild did the same (lane A's
probe: 6 creations and 5 removals for a want flipping each poll, 1 and 1 at start before RTSS hooked the game).
rtss_decide now counts the polls in a row a want stayed: a limit goes into the profile and a kept-out want puts it
back only after RTSS_SETTLE_POLLS (2), while standing aside still ends at once (rtss_limit_active compares the want),
and nothing is written before the first host present (RTSS_WHY_NO_PRESENT). A limit change reaches the profile up to a
second later; the game's limiter holds the new limit meanwhile. The created and removed lines were numbered before.

Not reproduced before the change. rtss_limit_check (T8: kept out for one poll and for two, a second reason, a limit
again; T9: 20 flips after a written limit give no write or put back, 20 from the start none, flips every two polls
act; no write while no host present) and rtss_worker_check (churn_run and every scenario that now settles) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:12:26 -05:00