Commit Graph

407 Commits

Author SHA1 Message Date
chrg6
1ae00ed802 Log dynamic resolution's target against the one the log last named, from the start line on.
The review found that dynres.c dynres_frame compared a changed target with a static that started at 0 and outlived a
restart: with fps_limit = display the first refresh-rate wander under 1 % after the start was logged, against the
comment's rule, and after dyn_res off and on a move back to the earlier run's last logged target said nothing, so the
start line and the live lines disagreed. A test of the menu's path found a third case: a limit or dyn_res_target
changed in the menu bumps cfg_version, dynres_read takes the new target first, and dynres_frame, which logged only
when its own reading differed from D.target_ns, never named it. The last logged target now lives in D (logged_ns),
the start line sets it, and dynres_frame logs any move of more than 1 % from it, naming the set target as "as set".
No lock and no wait; the target and the steps are what they were. The review's stale "(dyn_res_target 16.7 in
1..100)" in cfg.c misfit now reads 0..100, as the row's set has been since the commit that changed the default
target.

tests/dynres_check.py gains these cases (a menu limit, a menu target, a wander after the start, the earlier run's
target after off and on); they fail on the old dynres.c and pass. The -Wall compile of dynres.c and cfg.c shows no
warning, and cfg_rows_check reports 0 problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
4fe1339189 Say in nx2navi.ini.example's header which settings cut a comment after the value and which take the switch words.
The review found that the header promised more than cfg.c does in two places. It said a blank then '#' or ';' after
any number starts a comment, but cfg.c cut_comment cuts only for a row with a set of values, so the settings that
take any number (gpu_index, compute_keep, tex_released_max, floor_probe, floor_scan) keep the comment in the value:
"compute_keep = 256 # less" reads the default 512. And it said a setting of 0 or 1 takes off, false and no, but
switch_word maps the words only for a row whose set is exactly 0|1; a setting with choices 0, 1 or 2 such as
async_preempt or tex_disuse reads "off" as its default, on, with no line in the log. The header now says the comment
is cut after the value of a setting with a list of choices or a range, names those five number settings among what
keeps its whole text, and gives the words to the settings whose only values are 0 and 1. docs/building.md's paragraph
on tests/cfg_parse_check.py now also names the low-end switch's case and make_game.py's tests added by the previous
commit. Comments and docs only; cfg_rows_check reports 0 problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
b6709da852 Read a comment after a setting's value, and off, false and no for a switch, as players mean them in nx2navi.ini.
A hand-edited "pace_smooth = 0 ; off" kept the comment in the value, which the readers took as on, "fps_limit = 144 #
monitor" gave 60, and "fullscreen = false" or "rtss = off" were on, since every reader took anything but "0" as on;
the log only named these lines (the check added for F-7). Now, for a key whose row has a set of values, cfg_load cuts
an unquoted value at the first blank followed by ';' or '#' after the '=' ("key = ; note" is unset) and logs the line
once; free text keeps its whole value (window_size, monitor, audio_device and keys without a row such as mods_dir,
pipeline_cache and the bindings, where a path or name may hold " #"), as do a value in double quotes and the
environment's values. A switch (a row whose set is 0|1) written as false, off or no reads as 0 and as true, on or yes
as 1 through cfg_get itself, so every reader agrees (cfg_bool, rt_knob, the readers that compare with "0" or parse a
number); cfg_bool and rt_knob also take those words for keys of other rows, and rt_knob no longer logs them. F-7's
check now names a switch value only when it is none of those, and a comment kept in a value only where it stays
(free text, the environment). The launcher's reader (launcher/gameini.py) cuts the same way for keys the caller
says have a set of values; its mods keys stay free text. The menu's save still writes a changed key's line again
without its comment.

This changes what existing files with such lines do, which is what was approved. tests/cfg_parse_check.py,
tests/rt_knob_check.py and launcher_test PlayerIniTest cover the cut, the words, free text, quotes and the
environment, and pass; cfg_rows_check reports 0 problems; the runtime compile check and the -Wall comparison of cfg.c
and perf.c show no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
1c26907abe Aim dynamic resolution at the frame limit's frame time by default and hold each step for a time, not a frame count.
Dynamic resolution's target was 16.7 ms unless set, whatever the frame limit, so under a 120 or 144 fps limit the GPU
could take up to 18 ms a frame at full size before a step down, and the game drew at about 55 fps instead of the
limit. Each step was held for 45 presented frames, 0.75 s at 60 fps but 0.19 s at 240, so the size changed four times
as often there. The target now defaults to 1000 / the rate the game draws at under the limit (frame_rate.c
frame_step_game_hz: the monitor's rate for display, the drawn frames with frame generation), read every frame so a
limit changed in the menu moves it, and 16.7 ms while that rate is unknown or unlimited; dyn_res_target set to 1 or
more still wins (its row's default is now 0). dyn_res_step keeps its key, range and default, now counted in 60ths of
a second of the clock, so 45 is 0.75 s at any frame rate and the same as before at 60 fps. The switch stays off by
default.

Not reproduced before the change (read from the code). The new tests/dynres_check.py builds dynres.c with stand-ins
and passes (24 failures on the old code); cfg_rows_check reports 0 problems; the runtime compile check and the -Wall
comparison of dynres.c show no new warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
5c9e480f90 Swing the shake key's stick by the game's updates instead of the clock, two updates a side at any frame rate.
shake_stick picked the side from the time since the press, 67 ms a side (two frames at the game's 30 fps), so above
30 fps each side lasted more of the game's updates: 8 at 120 fps and 16 at 240, slower swings than the game reads at
its own rate. The side now comes from the game's present count, which frame_rate.c already kept for the flight
recorder and now gives through frame_step_presents, two presents a side. The press still swings for 800 ms of the
clock and a hold keeps it going, as before; while no present comes (a load) the stick keeps its side.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:03 -05:00
chrg6
5f9374e1d9 Keep the fraction of a frame the ocarina recorder's clock was losing above 60 fps, in a new patch set orecord.
The ocarina manager counts the length of each note and rest in whole 60 Hz frames and adds the time manager's frames
per update to that clock at four sites (main+0x32926c8, main+0x3293410, main+0x3296b08, main+0x3296da4) through
fcvtzu, which cuts the step toward zero. Above 60 fps the step is under one frame, so the clock never moved: a
recording kept its notes without their lengths (the recorder leaves a length of 0 out), the 360 and 120 frame limits
never came, and a recording ended after a fixed 60 frames of its timer instead of following the clock; at the 40 and
45 fps limits it counted one a frame. The set replaces each fcvtzu with nine words that add the fraction carried on
the patch page's +264, store what is left after the cut (NaN and negatives as 0) and convert the sum. Steps of one
and two frames give the game's count with the fraction left at 0, so 30 and 60 fps are unchanged. The words call
nothing and take no lock (one of the functions returns through an unsaved x30), and use only v16-v18, which the three
functions never name. The set is appended so that no existing set's index moves; ocarina_timing = 0 gives the game's
words.

Not reproduced before the change (read from the code). The generator's check of the original words passed against
the 1.1.1 main, the new tests/ocarina_timing_check.py passed (.venv, Unicorn), as did frame_step_check,
text_speed_check, sprint_check, movie_timeline_check, cfg_parse_check, launcher_test and cfg_rows_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
a40afdd86d Log a splitter framing fault met while the update is applied as the rest of the section left out, not as a refused update.
The review found that audio_mixer.c splitters_walk used AUDIO_REFUSE for a framing fault in both of its walks. In the
apply walk (audio_mixer_update_routing, after audio_mixer_routing_check's walk passed) such a fault can only come from
the game rewriting its input during its own RequestUpdate. The update is taken by then: it is answered 0, its voices
and effects and the splitter records before the fault are applied, and the mix section is applied after it. Yet the
log said "RequestUpdate refused with 0x5299 ...; the renderer goes on with the last update it took". SPL_FAULT now
refuses the update in the check walk as before, and in the apply walk logs the fault with "the rest of the splitter
section is left out". The same double read of the mix section by audio_mixer_mix_layout_check and
audio_mixer_update_routing stays as it is; its comment now says the check holds for the apply only because the game
does not rewrite its input during its own RequestUpdate. docs/building.md's audio_params_check paragraph names the new
case and is wrapped again at 120 columns.

Not reproduced before the change: the game's sending thread waits in the IPC call, so neither case has a known way to
happen. A new case in tests/audio_params_check calls audio_mixer_update_routing with a splitter section whose first
destination record's magic is broken after a valid update. It checks that the splitter info before the fault and the
mix section are applied, that the destination record is not, and that the one log line names the rest of the section
left out. It failed on the code before this change ("RequestUpdate refused with 0x5299: splitter destination 0 ...
not SNDD"). audio_params_check (27 refused updates, 11 left-out records, 38 effects and sinks, both strict runs),
check_content, a compile of every runtime file and -Wall -Wextra on the audio files and cfg.c with no warnings passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
9770d409d5 Say what NX2NAVI_AUDIO_STRICT, the audio_bad log lines and the docs really do, as the review of the audio change found them overstated.
The review found six texts that claimed more than the code does. NX2NAVI_AUDIO_STRICT=1 does not bring 0.10.1 back
exactly: it also ends the process on a limiter attack or release that is not a number and on a mix in use with a
negative buffer count, which 0.10.1 let through, so the cfg.c comment, nx2navi.ini.example, the audren.h audio_bad
comment and the test's comment now say it does so for most of these values. Its FATAL line described the non-strict
outcome ("its sound passes through unchanged", "the renderer goes on") and then ended the process; it now ends with
"NX2NAVI_AUDIO_STRICT=1 ends the process here instead". The audren.c header and docs/architecture.md said nothing the
game sends ends the process, while OpenAudioRenderer's parameters and the request's buffer descriptors still do; both
now say a value in the game's RequestUpdate does not, and name the two that still do. The first log line of a kind
gave "N such values so far", but bad_count counts every update and every render pass that meets any kind, so it now
reads "N times so far for all kinds together"; the renderer_close line and the bad_count comment say the same.
docs/building.md and the test's comment called 0x5299 the one result the SDK hands back; the SDK hands back 0x5299,
0x5499 and 0x5699 and aborts on any other. Texts only: no behaviour changes.

The first commit's message says any other result than 0x5299 makes the SDK abort; it should read any result but
0x5299, 0x5499 and 0x5699. The branch's history is kept, so this message carries the correction.

audio_params_check (the strict runs still end with "FATAL: audio:" and the switch's name), cfg_rows_check (0
problems) and check_content passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
7e7fed639e Refuse or leave out the audio values the game sends that the renderer cannot use, instead of ending the game on them.
The audio renderer checked the game's RequestUpdate with about 80 rt_fatal and need() sites in audren.c,
audio_mixer.c, audio_effects.c and audio_reverb.c, so a section size, a splitter or mix record or an effect setting
out of range (a reverb3d whose delays add up past 0.4 s, a limiter attack outside 0 to 1, a mix buffer index past its
mix, a filter coefficient that is not finite) ended the process. The tested route never reached one; dungeons and
bosses were never run. Now an update whose sections do not match the renderer is refused whole with 0x5299 before
anything of it is applied: the SDK's RequestUpdateAudioRenderer hands that result back to the game, which ignores it
and sends its next update, where any other result makes the SDK abort. A record with an id the renderer does not have
is left out of an update that is otherwise applied, a splitter destination id it does not have is stored as -1, which
both render loops skip, and an effect or sink whose values cannot be used is passed through or skipped in the render
pass, with every index checked before its first write. audio_bad logs the first of each kind per renderer with the
game frame and counts the rest. rt_fatal stays for host allocations, OpenAudioRenderer's parameters and the checks
the new ones turn into invariants. Two inputs that did not end the process before change too: a mix in use with a
negative buffer count is refused (it laid the mixes after it out before buffer 0), and a limiter attack or release
that is not a number passes the sound through instead of turning the mix into NaN. NX2NAVI_AUDIO_STRICT=1
(audio_strict, default 0) makes audio_bad end the process as before, for bug reports.

Not reproduced before the change in the game; crafted updates reproduced the fatals. The new
tests/audio_params_check sends 27 refused updates, 10 records and 38 effects and sinks the renderer cannot use
through renderer_handler and runs the strict switch; 15 deliberate breakages of the change each made it fail.
audio_mixer_check, cfg_rows_check, check_content, a compile of every runtime file with no new -Wall -Wextra warnings
in the changed ones and the stability pass's test matrix (the two baseline environment failures only) passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
b0baa981ff Keep a recording copy that one synchronous submit call gives twice in place both times, instead of cloning the second occurrence as if another thread's submission were in flight.
The review of 6a0f123a found that nvn_submit_prepare took the same handle given twice in one nvnQueueSubmitCommands
call for another thread's submission in flight: the first occurrence takes the copy's busy count from 0 to 1 and
resolves in place, so nvn_copy_enter returns 0 for the second, and unless the copy was known to hold no deferred binds
(deferred 1; it is 0 at the first submission and 2 for any copy with deferred binds) the call cloned it, a malloc and
copy of up to 1-2 MiB and a pin walk under copy_lock shared. It also logged "thread N submits the recording ... while
another thread's submission of it is replayed", the line the play-test reads to tell whether F-2-03's remainder
happens in this game at all, so a call that repeats a handle would have given a false positive. The asynchronous path
already keeps such a repeat in place (nvn_submit_prepare_async: used_at != now).

Now runtime/nvn_submit_guard.h nvn_copy_again says whether the handle came earlier in the same call and resolves in
place there; nvn_submit_prepare asks it only after nvn_copy_enter found the copy busy and the copy may hold deferred
binds, so only that branch pays for the scan. Such an occurrence stays in place and keeps its busy count, which
nvn_submit_done leaves per occurrence as before. Another thread that submits the copy meanwhile still takes a clone.

tests/submit_clone_check gives the copy twice in a quarter of its calls (76,759 repeats kept in place in 800,000 calls,
no replay saw its handles change, the counts end at 0) and checks nvn_copy_again step by step: given twice with
nothing else in flight, in place both times; with another submission in flight, cloned both times.
docs/building.md says so.

Not reproduced before the change. Checks that passed: submit_clone_check, pass_cc_check of nvn_api_cmd.c and
nvn_api_core.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
618cfc01f4 Run a frame's bookkeeping that is still owed on the present thread before the frame's blit, so the blit never reads the upscaler state of a frame vk_dlaa_frame has not closed.
The review of cd2fd454 found that with present_order = 1 the present thread could record and queue frame N's blit
before frame_end(N) ran: when the presenting thread's try of the submission lock failed, frame_end(N) waited for the
next submission, and that could be the present thread's own blit submission, which present_main makes only after
vk_present_blit. The blit reads what vk_dlaa_frame resets when the upscaler or frame generation is switched on or off
(gfx_present.c hudless_record through vk_dlaa_fg_hudless; vk_fg.cpp, vk_fg4.cpp and vk_dlssg.c through
vk_dlaa_fg_inputs, vk_dlaa_fg_views and vk_dlaa_fg_camera). vk_dlaa_frame drops those inputs at a switch because
handing them to frame generation ended an AMD process before, and in 0.10.1 vk_dlaa_frame(N) always ran before blit
N. So a switch frame inside an overlap window could hand frame generation the inputs the switch should have dropped.

Now present_image tells the present thread when it left the frame owed (PresentJob.owed), and present_main first
makes sure that frame's bookkeeping ran (present_frame_closed, vk_frame_owed.h fo_close): when a submission did it
meanwhile, done says so (it is now stored with release after each frame's bookkeeping and read with acquire, fo_ran)
and no lock is taken; otherwise the present thread takes the submission lock, waiting for a replay in progress
(vk_sync.c vk_sync_locked), and runs what is owed. frame_end(N) therefore always comes before blit N, as in 0.10.1.
The present thread is not one of the game's threads, and it waits for the same lock for the blit's submission a
moment later anyway. It waited for that lock with its busy flag set before too, and nothing that holds the lock waits
for the present thread, so no deadlock is added. The cost, only for a frame still owed when the
present thread takes it: the blit's recording no longer overlaps the replay it waits for, which can lengthen a drawing
thread's existing wait in vk_present_drain by that recording. The common frame, whose present got the lock, takes no
lock here. The log's frame bookkeeping line (exit, and trace = perf every 600 presents) counts these frames and the
time they took the present thread, the wait included.

tests/frame_owed_check now has a present thread that takes each frame from the presenting thread (one in waiting at
most), blits it and submits: no blit comes before its frame's bookkeeping. Its "old" argument leaves the catch-up out
and fails, as the branch had it: 2608 and 2706 blits before their frame's bookkeeping in two runs of 20,000 frames.
docs/building.md, docs/architecture.md and nx2navi.ini.example say where the bookkeeping runs.

Not reproduced before the change. Checks that passed: frame_owed_check (20,000 frames; 12,695 owed, 1,888 of them
caught up by the present thread, 0 blits before their bookkeeping, the longest present 0.003 ms), pass_cc_check of
every runtime file (headers changed), no new -Wall -Wextra warnings in vk_backend.c and vk_sync.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
effdbd8dc0 Describe in docs/architecture.md how the game's submitting threads take turns, and bring nvn_call_note's comment, which said the front end relied on one thread at a time, up to date.
The submit path no longer relies on one guest thread at a time: replays take turns on the submission lock, a frame's
bookkeeping at its present runs between two replays (vk_frame_owed.h), a held present goes out once and in order
(vk_present_held.h), a second submission of a recording in flight gets a clone (nvn_submit_guard.h), and held work is
asked about from a published copy (vk_evo_peek.h). docs/architecture.md section 4 gets a bullet saying so, with the
switch and the log lines; the comment above nvn_api_core.c nvn_call_note now says what holds and that an overlap line
is a count, not a fault.

Checks that passed: pass_cc_check of nvn_api_core.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
58fad9128d Give a synchronous submission of a recording copy that another thread's submission still replays a clone of its own for its deferred binds, instead of rewriting the copy under that replay.
nvnQueueSubmitCommands resolves a copy's deferred binds at the submit call: the handles the game's arrays hold now are
written into the recording copy (nvn_api_cmd.c resolve_deferred). With the replay on the submitting thread, a second
guest thread that submitted the same copy while the first one replayed it, or while the first copied it for a held
continuation (vk_exec.c cont_new), rewrote those handles under that replay: the rest of it bound the next frame's
textures and samplers, and a held continuation could be copied torn (F-2-03's part left after the private copies).

Now each copy counts the synchronous submissions between their prepare and the end of their replay (copy_prefix.busy,
which shares the 8 bytes pins had, so the prefix keeps its 56 bytes). nvn_submit_prepare takes copy_lock shared once
for all handles: the first submission in flight resolves the copy in place, as before; one that finds another in
flight, unless the copy is known to hold no deferred binds, resolves a clone made under that lock, with its texture
pins counted, and nvn_submit_done frees it after the replay, as the asynchronous path does for a copy a queued
submission still reads. A copy the game releases while a submission of it is in flight becomes a zombie and is freed at
the next sweep after it ends (release_copies, zombie_sweep). The bind loop moved unchanged into
runtime/nvn_submit_guard.h (nvn_resolve_binds) so a check can build it; it reports a corrupt record or a call past 16
deep to resolve_deferred, which ends the run with the messages it had, and keeps the first six changes' trace line.
Being code the front end carries under its MIT header, the new header keeps that line and THIRD_PARTY_NOTICES.md's row
names it. A clone is logged the first four times and at each power of two ("submits the recording ... while another
thread's submission of it is replayed"). No timing change and no wait: the clone path runs only when two threads have
one copy in flight at once.

Not reproduced before the change; nobody knows yet whether the game ever submits one copy from two threads at once (the
new line will say). Checks that passed: the new tests/submit_clone_check (four threads submitting two copies 200,000
times each while their arrays change: no replay saw its handles change, both paths ran, the counts ended at 0; the
unguarded 0.10.1 shape through the same harness: about 450,000 of 800,000 replays saw them change; nvn_resolve_binds
step by step), copy_sweep_check (its stand-in prefix follows the new layout), pass_cc_check on every runtime file, no new
-Wall -Wextra warnings in nvn_api_cmd.c and nvn_api_core.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
51c9261db3 Close a frame's replay state under the submission lock: at the present when the lock is free, else first thing in the next submission, so another thread's replay never sees it reset halfway.
present_image ran the frame's bookkeeping on the presenting thread without a lock: vk_exec_frame_log (the trace counts
and vk_exec_cond_frame_end's frame count, which the conditional draws go by), dynres_frame, vk_exec_barrier_log,
vk_dlaa_frame (the DLSS mode and its LOD bias, the frame's points, the swap image, the idle releases), vk_mvsurvey_frame,
vk_mvprev_frame (which swaps the two motion tables the draws insert into) and vk_gpu_passes_frame. All of it is state the
replay reads and writes as it records, under sub_lock. With the replay on the submitting threads the game presents on
one thread while another may be replaying (the overlap lines of the 0.10.0 fresh session, at area changes), so the
present reset it in the middle of that replay: a DLSS frame reset halfway, the motion tables swapped halfway (with
MVPREV_CHECK=1 an index out of bounds), the conditional draws counted against the next frame.

Now the bookkeeping is frame_end, and runtime/vk_frame_owed.h decides where it runs. present_image marks its frame owed
and runs it itself only when it gets sub_lock at once (vk_sync.c vk_sync_try_locked); otherwise vk_sync.c submit runs
every owed frame's, in frame order, right after it takes the lock and before its replay (vk_present_frames_owed), on
whichever thread submits next, the present thread's blit submission included. The presenting thread never waits for
another thread's replay for it: no new wait on a drawing thread; the thread that takes the lock next does the work it
would have done at the present, tens of microseconds normally. The hand-off, vk_texture_get with its upload flush and
vk_present_drain stay outside the lock, as frame_end must not wait for the present thread or submit; its callees were
checked for that. vk_sampler_aniso_live and vk_mem_census keep their place in present_image (their own locks). In the
asynchronous replay the other holder of sub_lock is the present thread's blit, and the same applies.

NX2NAVI_PRESENT_ORDER (present_order in [diagnostics], default 1, read at start) puts it back at the present with 0, as
in 0.10.1: metas, known and sections rows and an nx2navi.ini.example entry.

Not reproduced before the change. Checks that passed: the new tests/frame_owed_check (one presenting and three
replaying threads, some replays holding the lock 40 ms, over 20,000 frames: every frame's bookkeeping once, in order,
never during a replay and before any replay that took the lock after its present; the longest present under 1 ms with
19,000 presents made during a long hold), cfg_rows_check with 0 problems, pass_cc_check on every runtime file (headers
changed), no new -Wall -Wextra warnings in vk_backend.c, vk_sync.c, vk_exec.c and cfg.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00
chrg6
a789bd8fb0 Put a held present out under a lock of its own that submitting threads only try, so that it goes out once, whole and before the next present.
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>
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
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
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
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
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
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
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
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
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
1f547853e3 Put back the backslash that had turned into a tab in docs/building.md's tex_compact_check command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:46:50 -05:00
chrg6
6b051eea58 List kern_ap_stub_ymm as dispatched in check_isa.py, so that a --cpu v2 build passes its instruction check again.
kernel.c's preemption stub has two bodies since 00f06e66: kern_ap_stub_ymm saves YMM0-15 with vmovdqa and is chosen only when ap_init's cpu_keeps_ymm() finds AVX, OSXSAVE and XCR0 bits 1 and 2, and kern_ap_stub_xmm serves every other processor. tools/check_isa.py's DISPATCHED listed only sha256.c's blocks_ni, so it took the YMM body for an undispatched AVX function and stopped every tools/build_game.py --cpu v2 build. The entry names that check. Players' builds (make_game.py) run no such check and were not affected.

Reproduced before the change: kernel.c built with the ucrt64 clang at -march=x86-64-v2 failed tools/check_isa.py with "kern_ap_stub_ymm ...: vmovdqa x32, vzeroupper x1", and passes now. Checks that passed: the new tests/check_isa_test.py (kernel.c and sha256.c built with -march=x86-64-v2 and without -march pass; an AVX function not in DISPATCHED still fails; it fails before the change) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00
chrg6
56db0d485c Make check_platform.py skip the branches that only a Windows build compiles, so that it passes on the runtime and guards it again.
tools/check_platform.py scanned the raw text of each shared runtime file and knew nothing of preprocessor branches, so the Win32 use that input.c, mods_probe.c, rtss_limit.c, vk_cache_file.h, vk_dlaa.c, vk_dlssg.c, vk_dump.c, vk_ffx.cpp, vk_ffx4.cpp, vk_fg.cpp, vk_fg4.cpp and vk_pipeline.c keep behind #ifdef _WIN32 made it fail on every run, at f1a2a64e already: a check that always fails guards nothing. It now leaves out the lines of a Windows-only branch (#ifdef _WIN32, #if defined(_WIN32), #if _WIN32 up to their #else, #elif or #endif, the #else of #ifndef _WIN32 or #if !defined(_WIN32), and conditionals nested in them), found on the text with its comments blanked. Every other branch is still scanned, #elif defined(_WIN32) and #if defined(_WIN32) && X among them, which can fail the check but never lets a use through. docs/building.md lists the check and its test under "Checks".

Reproduced before the change: the check exited with 1 and these 12 FAIL lines on 2f640fb8; it now exits with 0. Checks that passed: the new tests/check_platform_test.py (guarded uses pass, unguarded ones fail, the repository's runtime passes; it fails before the change) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00
chrg6
814c10b355 Keep the Steam shortcut from starting the game while the launcher runs a job in the game folder.
The Steam shortcut's --play runs before the window's single-instance lock and checked only whether the game was running. Started while the window rebuilt the game, installed an update or built the mods' code, it ran mods.prepare into the folder the job was reading and started the old ocarina.exe, and the job then failed at the swap with "ocarina.exe is in use" (an update's install left the new launcher on the old build); during the shaders step, make_game.py's own run of the exe made the shortcut log "already running" and do nothing visible. play_only now tries the launcher folder's lock and lets go of it at once: when another process holds it and data/job-running.json names a job (jobs.running_job, which leaves the marker in place), it logs that the launcher is busy, says which job to wait for in a message box and does not start the game. A marker that a stopped launcher left does not stop it, since its lock went with its process, and neither does the window open without a job.

Not reproduced before the change in a game run: found by reading the code. Checks that passed: tests/launcher_test.py (216 tests, a new one for a held lock with the job's marker, a leftover marker with the lock free, and the window open without a job, which fails before the change) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00
chrg6
8f42883a74 Tell the end of a game started from the Steam shortcut as Play tells it, with a message box for a fatal one.
play_only, the --play path of the Steam shortcut, logged only "the game ended (code N)" in launcher.log: it read no FATAL line, added no note to the game's log and showed nothing, so a game that ended with 70, 71, 72 or a Windows status left the player with no word of why (as when Steam starts it while the window replaces a save, and it ends at once with "another copy of the game is running"). The 7b0dbeb1 change that logs every end touched the window's watch only. play_only now follows the run with GameRun, the Restart's copies too, and takes the same game_end as the window: the line in launcher.log, the note in the game's log, and a message box only for an end worth one (a FATAL line, 70, 71 or 72, a Windows status; not 0, nor 1 from Task Manager's End task). The logs are written before the box, which waits for OK while Steam still counts the game as running.

Not reproduced before the change in a game run: the audit's probe showed it with a stand-in process. Checks that passed: tests/launcher_test.py (214 tests, a new one for the Steam shortcut's ends with codes 70, 0xC0000005, 0 and 1, which fails before the change) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00
chrg6
8de99df6eb Explain a lost device's exit code 72 with the game's FATAL line, and follow the copy the game's own Restart starts for the code it really ends with.
The launcher read the game's FATAL line only after codes 70 and 71, so a run that ended through vk_device.c vk_device_lost_check (code 72, as in the AMD device losses of 2026-11-12) showed a bare "code 72" with no cause. 72 now joins 70 and 71 in FATAL_CODES and has a text of its own, which stands without a FATAL line because the loss's watchdog (vk_device.c lost_wait_exit) can end the run before that line is written.

The game's Restart (gfx_present.c gfx_window_closed) starts the exe again and ends the process the launcher started with code 0, and the launcher took that 0 as the run's code. A restarted copy that ended with 70, 71 or 72 was logged as an end with code 0 and its FATAL line was never shown; one that was killed or hung got a note saying code 0 in its log. Once the process it follows has ended with 0 while the exe still runs, the launcher now finds the copy by its image file (winsys.game_copy, compared as a file so that another install's game is not taken), holds a handle to it and takes its exit code; a further Restart is followed the same way. A copy it could not find ends with its code logged as not known. After code 0 or an unknown code, a FATAL line in a log written since the start can only be such a copy's, and it is shown as one; a log older than the start (a run that ended before it opened its own) is not read. A log cut at its 64 MiB cap (main.c log_sink) wrote its end lines to stderr only, and no longer gets a note saying it has no line of its own end. The end's handling is one function, game_end, for the Steam shortcut's play to share.

Not reproduced before the change in a game run: the audit's probes showed each of these ends with stand-in processes and logs. Checks that passed: tests/launcher_test.py (213 tests, new ones for code 72 with and without its FATAL line, the followed copy, a copy not found, a FATAL line after code 0 and a log older than the start, the cut log, and game_copy on a sleeper compiled with the ucrt64 clang) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00
chrg6
78bfb83b2b Ask the shipped translator itself for its texture pool length before make_launcher packages it.
The review of the packaging guard (b4c21998) found that D-1's optional behaviour probe was left out without a reason:
check_built compares only nx2navi_xlate.id with the checkout's sources, so an old exe beside a current .id still
passed, and while the exe build record waits (G-12), a probe of the exe is the only landable check on the exe itself.
It also passes the release scripts' folder, as that translator declares 40960 entries and clamps to 40959.

tools/xlate_smoke.py gains pool_check(exe): it sends a hand-assembled fragment shader that reads a texture handle from
an input (IPA R4 = a[0x80]; TXQ.B R0, R4; EXIT), which the translator reads from the texture pool array, and refuses
an exe that does not run or answer, whose res.json texture pool entry (pool 1) has another array length than
tools/xlate/Program.cs TexturePoolLength, or whose SPIR-V lacks that array or a GLSL.std.450 UMin by length - 1
(spirv_clamps reads the module itself, no disassembler needed). xlate_smoke.py runs it last and takes --exe.
make_launcher.check_xlate runs pool_check on the exe after check_built on Windows, still before anything is
downloaded, built or deleted.

Not reproduced before the change beyond the audit's probe. On this PC, pool_check passes on work\xlate_6866a13e (and
make_launcher.check_xlate on that folder), refuses the 0.9.2 translator (identity 4d051299) with "declares the texture
pool with [32768] entries, where its sources say 40960", also through check_xlate beside a current .id, and
xlate_smoke.py --exe passes on work\xlate_6866a13e. tests/xlate_package_check.py now checks spirv_clamps on a module it
assembles, that pool_check and check_xlate refuse an exe that does not run beside a matching .id, and that a
translator built in build/xlate passes when there is one; it passes. docs/building.md names pool_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:45 -05:00
chrg6
75b1e0b5d9 Say that pipeline-binaries.bin is cut back also at a malformed record, and check the cut itself.
The review of the torn-file fix (43ac5ae3) found that pb_index also stops at a malformed record in the middle of the
file, so pb_init cuts the file back to it and drops the whole records after it, while its log line and the comment
above the cut spoke only of a record half written. It also found that tests/pb_torn_check.py covered only the index
walk, not the keep-and-cut that is the fix. The decision after the walk moves from pb_init into pb_keep(path, good, len),
which returns the length to go on with (len when the records fill the file, good after cutting it back) or 0 when the
file cannot be cut and starts empty; its log line now reads "<path> ends inside a record or holds a malformed one at
byte <good>; <n> bytes from there dropped", and the comment and the start-empty reason say the same. Behaviour is
unchanged.

pb_torn_check.py now also cuts pb_keep out of vk_pipeline.c and runs it on real files, with plat_file_truncate a
stand-in that cuts with the C library: a torn file is cut to its whole records and logged, a second walk reaches the end
of what is left, a record appended after the cut is read back with the others; a malformed record in the middle is cut
off with what follows; a whole file is left as it is, unlogged; and a file that cannot be cut gives 0 and stays as it
was. The check passes, vk_pipeline.c compiles, and its -Wall -Wextra warnings are the four it had, none in pb_keep or
pb_init. docs/building.md names pb_keep and what the check now covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:45 -05:00
chrg6
a47c6d69de Say in docs/architecture.md which carried translations are translated again after an update.
The review of the carry refusal (a2de7eb7) found that the "Translator updates" bullet still said a shader the new
translator has not translated yet uses the previous one's for the whole session, with no wait at the start or in play.
Since that commit, a previous translation whose texture pool array has the earlier length (32768 entries, the 0.9.2
translator's) is not used: such a shader is translated again, in the boot prewarm when the boot names it, otherwise at
first use, with its draws skipped meanwhile and a compute dispatch waiting for the translation. The bullet now says so,
names gfx_shader.c carry_usable, and says that a program the current translator fails keeps even such a translation
(carry_last). The bullet is rewrapped at 120 columns.

Documentation only; tools/check_content.py passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:45 -05:00
chrg6
6c6e7b3974 Serve a refused carried translation once the current translator has failed the shader.
The review of the carry refusal (a2de7eb7) found that it also took the old translation away from programs the current
translator cannot translate. Before it, carry_find served the carried translation before any translation ran; after
it, a carried translation with the earlier texture pool length was refused, the translation ran and failed, and get_one
marked the key failed for the session (xlate_worker marked its wait failed), so the shader's draws were skipped, or a
compute dispatch left out, for the whole session, where 0.10.1 drew them with only the textures numbered from 32768 up
wrong. That contradicted docs/architecture.md ("A program the new translator refuses keeps its old translation") and
the carry pass's "its previous translation stays in use".

gfx_shader.c carry_last now runs where a translation failed (get_one, xlate_worker, and get_async_one's synchronous
fallback): carry_find with the new short_ok argument set passes carry_usable by, so the refused translation enters the
memory cache, where the lookups' memory step serves it from then on; the key is not marked failed and the wait is
cleared as for a success. The first four are logged with their key. Every other caller passes short_ok 0.
pool_short_note still counts such a shader for vk_texture_register's warning but no longer logs it as a translator in
use older than the runtime, and the refusal's log lines say that a shader the current translator fails keeps its old
translation. With this, the carry pass's "its previous translation stays in use" is true again for these programs, and
keeping their previous entries (the pass does not prune a failed input's) is what this needs.

Not reproduced before the change. tests/carry_pool_check.py now also cuts carry_last out of gfx_shader.c and checks that
it serves the refused translation into the memory cache, which the memory lookup then finds, serves a usable one
unlogged, returns NULL with none and still refuses another stage, and that pool_short_note counts a carried shader
without logging it; it passes, every runtime file compiles, and gfx_shader.c has the same 7 -Wall -Wextra warnings as
on stability-pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:45 -05:00