Commit Graph

302 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
b4556957c2 Add a hang watch that logs a report when no frame is shown with the window in front, or the GPU does not finish, for 20 s.
A present, a submission or a completion that never came back (a deadlock, a driver wait that does not return, a
GPU hang without a reset) left a log that just stopped: kernel.c wait_watch covers guest lock and thread-end waits
only and is off by default, and nothing watched the present, submit or completion threads.

hang_watch.c runs a thread of its own in a game run (main.c starts it before the boot; never in the tool modes).
Once a second it reads, without a lock, gfx_counters.frames and the submissions issued and done (the new
vk_sync.c vk_sync_progress, no device call). It reports a stall of the frames while the guest runs, the window is
in front and not minimized (gfx_window_in_front) and no orderly end has begun (rt_exit_started): the game stops
presenting without the focus, so a focus change starts the count again, and the boot screen is not judged. It
reports submissions issued and not done, "done" unchanged for N s, whatever the focus. One report per stall, a
new stall needs progress, at most 3 a run. A report names the stall with the counts, the focus and the world
clock; who holds the present, queue and window-start locks (gfx_present.c gfx_lock_holders, read from
PlatRMutex.owner and depth); the guest threads' lock and thread-end waits (kernel.c kern_wait_report, wait_watch's
walk factored out as wait_pass, run with limit 0 under ten 10 ms trylocks of the kernel lock); and then writes the
log out with logq_flush, never rt_log_sync, which would put every later line on the thread that logs it in a run
that may recover. It never ends the process and adds no wait to a drawing thread. The per-thread wait sites and
lock owner wrappers are left for a later change.

NX2NAVI_HANG_WATCH_S sets the seconds (default 20, 0..600, read at start, 0 off): a metas row, a known[] name, a
diagnostics sections[] entry and an nx2navi.ini.example entry.

Not reproduced before the change (a diagnostic). The new tests/hang_watch_check.py drives hang_watch.c's decisions
on made-up readings: no report while things move, unfocused, minimized or ending; one per stall of the frames with
the window in front and one again after progress; one for submissions not done whatever the focus; none for an
idle GPU; at most 3; logq_flush called and logq_sync never. tests/cfg_rows_check.py (0 problems), the scratchpad
rows check ("missing []") and the compile checks of every runtime file pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:34 -05:00
chrg6
601a954676 Say in nx2navi.ini.example how much game memory the copy sweep reads.
The review found the copy_retire text inaccurate: it said the thread reads 16 bytes of game memory per copy every 5
seconds. The sweep (nvn_api_cmd.c retire_pass) fingerprints each new copy once and visits at most 4096 known copies per
5-second pass. The text now says so. No code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
811024e69b Count the recording copies whose control memory the game reused, behind the new NX2NAVI_COPY_RETIRE switch.
Recording copies are retired only when the game adds their control memory again (release_copies), and the fresh
0.10.0 session's "mem: sizes" lines show them growing for good: 220 at frame 600, 223,646 (56.3 MiB) at frame 39600,
with no zombies. This is the first stage of retiring them: it only counts, so the second stage can be judged on
numbers. With copy_retire = 1, copy_add queues each copy's control position, and an "nvn copy sweep" thread at the
lowest priority fingerprints the 16 bytes there within 5 seconds, then visits up to 4096 copies every 5 seconds from a
cursor and counts those no submission used for 5 minutes whose bytes changed or whose memory is no longer mapped. The
"mem: sizes" line gains "of those idle, control memory changed" and "control memory gone". Nothing is freed.

Our driver never writes control memory, so the bytes compared are the game's own leftovers; a reuse that writes the
same bytes is not seen and the counts are a lower bound. Every read of guest memory happens on the sweep thread, never
on a recording thread: a lazily armed range is not read at all, an untouched lazy-heap page is taken as zeros, and the
read holds the mapping with mem_read_hold. The sweep takes copy_lock shared only, 64 lookups at a time, and its queue
has a lock of its own that is never held with copy_lock. Copies that share a control position (EndRecording falls
back to the block's start when it is full) each keep their own record.

The age ring behind "of them unused for 5 minutes" grows from 1024 to 65536 minutes: at 1024 a copy last used 17 hours
earlier shared its slot with the minute now and counted as recent.

New row COPY_RETIRE (0|1, default 0, restart) in known[], sections[] (diagnostics) and metas[], and a copy_retire entry
in nx2navi.ini.example. tests/copy_sweep_check.py cuts the fingerprint, visit, queue and pass functions from
nvn_api_cmd.c with stub copies and stub guest memory: changed bytes count as changed, unmapped memory as gone,
unchanged bytes and copies used within 5 minutes count nothing, armed ranges and untouched pages are never read, retired
and replaced copies leave the counts, and two passes over 5000 copies visit each once or twice. The cfg row check
reports no missing rows, launcher_test passes, and the compile check adds no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
ae90aff58a Keep every pipeline when tex_vram_pressure is 0, as the textures read it.
nx2navi.ini.example says tex_vram_pressure = 0 means the textures never give way for video memory, and vk_texture.c
sweep_pressure reads 0 as off, but pressure_release mapped 0 to the default 80 %, so the pipelines no draw had used
were still given back over 80 % of the budget. pressure_release now leaves them for the run at 0 and logs so; values
outside 0..100 still fall back to 80. The pipe_release paragraph of nx2navi.ini.example says that tex_vram_pressure = 0
turns it off too. No cfg.c row changes.

Not reproduced before the change; nothing changes at the default (80). vk_pipeline.c compiles with no new warnings.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
26b7cfd658 Put the process's OS handle count on the sizes line and add a handle census by kind behind NX2NAVI_HANDLE_CENSUS.
A session of 2026-11-13 grew from 683 to 1095 OS handles in 11 minutes, rising at every area load and never falling,
while the guest's own handles stayed at 116-122 and the threads flat; the VMA block count fell back while the handle
count did not. The log could not show it: the 600-frame "mem: sizes" line had only the guest's handles, and nothing
told which kind of handle grows.

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

New tests/handle_census_check.py builds the platform layer alone: 20 events and 5 sections show as Event +20 and
Section +5, the total stays within 2 of GetProcessHandleCount, the figures return once they are closed, and kinds
past the room given go into the last entry; docs/building.md describes it. Not reproduced before the change (the
growth needs a game run). handle_census_check, sync_wake_check and the cfg row check pass; every runtime file compiles
with the dev flags, and perf.c, kernel.c, win32.c and cfg.c add no -Wall -Wextra warnings.

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

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

Docs only; check_content passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:39 -05:00
chrg6
1fa8b61b87 Give back the ASTC decoder's grown scratch 5 s after its last large texture, not at the next upload.
A scratch grown past 32 MiB for a large texture went back to the usual size only when the next ASTC upload asked for one, so in the 2026-11-13 session the decoder held 224 MiB for the 70 s after the first area load and from a later load to the session's end. Each present now gives it back once no texture needed more for 5 s; nothing is made then, the next request makes the usual scratch, and the buffer is freed once the upload batches recorded so far completed, as before. The present only tries the decoder's lock, which can be held while VMA makes a block. astc_scratch_trim = 0 keeps the scratch until the next upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:52 -05:00
chrg6
2969005edc Past the hard count of released textures, drop the cheapest ones first, in one walk per batch.
Past RELEASED_HARD (16,384) the sweep dropped the first released entry in release order that was overwritten or unsampled for 2 s, render targets included, whose drop vk_texture.c already ties to a black frame, and it walked the list from its head for every drop. At the end of the 2026-11-13 session the count sat at 16,384 for a minute while 12,811 of 15,734 released entries owned no memory. The entries now go by class: overwritten ones, then ones that come back intact and are past their usual take-back gap, then other intact ones, and only then ones whose content only the GPU made; still only unsampled ones, and the one sampled longest ago when all were sampled lately, as before. One walk takes a batch down to 256 under the bound. tex_hard_order = 0 keeps the rule of 0.10.1.

The drops past the hard count get a reason of their own, so the texture origins line and the reuse line count them apart from the sweep's drops past RELEASED_MAX, with the classes they came from. The class rules are in vk_tex_release.h and checked by tests/tex_release_check.py.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 15:57:51 -05:00
chrg6
46d71da8aa Draw task and mesh programs through the mesh fallback by default on AMD's Windows driver for RDNA 1 and 2, where native mesh pipelines lost the device in play; mesh_fallback = 0 draws them natively again.
Every RX 6000 loss whose pass marks named a pass was in one that starts with a native mesh draw, on every version from 0.6.0 to 0.9.2 and with every other setting tried each way; RDNA 1 cards on the same driver draw through the fallback and RDNA 3 and 4 on AMD's other driver draw natively, and neither has such a report. That the native mesh path is the cause is not shown. The decision moves into vk_mesh_path.h with a test of each device kind and setting; has_mesh_shader keeps saying what the hardware has, so the recorder thread and the low-end card test do not change. On those cards no native mesh pipeline is built any more: the seed list and the boot prewarm skip them. The early tail stays off with the fallback, a combination never measured. The fatal line names the mesh path, and NX2NAVI_VK_NOOPT's default applies with mesh_fallback = 0 alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:54:24 -05:00
chrg6
a84a3ab12d Make the release 0.10.1: every setting under the Game tab's Testing heading is off by default again, CPU helper threads (replay_workers = 0) and Start next frame early (tail_early = 0) among them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 10:49:01 -05:00
chrg6
26d26babe5 Bring public main's bug capture on F8 and its removal of the Steam co-op into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture.md
#	launcher/steamlib.py
#	launcher/ui/app.py
#	runtime/boot.c
#	runtime/cfg.c
#	runtime/flight.c
#	runtime/flight.h
#	runtime/gfx_present.c
#	tests/launcher_test.py
2026-11-13 09:52:43 -05:00
chrg6
8a99af1080 Give back the graphics pipelines no draw has used in this run while video memory is short: over tex_vram_pressure percent of the budget, the autosave thread takes up to 256 of them out of the table every 5 seconds and destroys them once the GPU is past the submission being recorded; a draw that needs one later builds it again from the pipeline cache. Mesh pipelines, entries building or linking and compute pipelines stay, and the next pass logs the video memory in use before and after. pipe_release = 0 keeps them all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:19:50 -05:00
epicmeme546
f5da269add Remove the experimental co-op through Steam.
This takes back the three commits that added it (cd2f9f1, e60677e, a01910d): its runtime layer and Steam binding, the Co-op tab and what it drew over the game, the launcher's copy of Steam's library file, its settings, tests and docs. The tree is as it was before them, with the bug capture that came after kept: its note on screen no longer makes room for co-op's lines, and dbg_area, which the co-op had brought and the capture uses, stays.

The four settings of that co-op join the retired names, so a settings file that still has them logs no unknown key. A steam_api64.dll that the launcher put beside the game is left where it is: nothing loads it any more.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 09:08:40 -05:00
chrg6
e4df494048 Turn the early tail on by default (tail_early = 1): the game starts its next frame while the graphics card runs the last one's upscaler and interface, and a frame whose end hands the game anything else runs as before; tail_early = 0 turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:58:03 -05:00
chrg6
5faa1687f4 Add quad_index, which draws an indexed quad list as two triangles per quad and closes an indexed line loop, from the game's own indices, and log the first such draw either way.
Vulkan has no quad or line-loop topology. Draws without indices were rewritten already; an indexed quad list went to the GPU with its indices under the triangle list topology, three at a time, joining corners of neighbouring quads, and an indexed loop lacked its closing line. Off by default and read at start; no log of Ocarina has shown such a draw so far, and the new line says when one comes. tests/index_rewrite_check.py checks the index lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
9d5f16bda4 Add oq_hold, which makes a samples-passed report whose draws the full occlusion query ring left uncounted repeat its record's last complete count, and log how often such reports happen.
A draw that finds every query slot still waiting for the completion thread goes uncounted, and the report after it said only what the other draws counted, at least 1: the game takes the object it tested for all but hidden for that frame. With the setting the report writes the larger of that and the last complete count reported into the same record. Off by default and read at start; the count of such reports is logged at the 1st, 10th, 100th and so on either way. tests/oq_hold_check.py checks the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
cc5c8f87ad Add shadow_pcf, which makes every depth-compare sampler filter linearly so each shadow test blends the 2x2 shadow map texels around it, and log the filters of the compare samplers made.
Through a nearest filter each of the game's shadow compares tests one texel, so shadow edges are stair steps of shadow map texels that crawl over the ground as the sun cascades follow the camera. The setting is off by default and read at start; with shadow_scale above 1 the samplers were already linear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:36:46 -05:00
chrg6
fadd4a6d5e Copy large textures out of the game's memory on several threads at once: a level of 1 MiB or more goes to a small worker pool as bands of rows (par_jobs.c, tex_deswizzle.c tex_bl_copy_mt), and so does a large block copy for the GPU's ASTC decoder, so the upload's storage copy no longer runs on the drawing thread alone. The bytes are those of the one-thread copy; par_jobs = 0 turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:35:42 -05:00
chrg6
3573a96f46 Keep the SPIR-V of shaders nobody read for 30 seconds compressed in memory.
The shader memory cache held 777 MiB of SPIR-V for 7115 shaders for the whole session. A background thread now
compresses the SPIR-V of each shader no reader pinned for shader_compress_idle_s seconds (zstd level 1, 26% of the
bytes on the test install's 44,151 modules) and frees the plain copy; readers pin it through gfx_shader_spirv_pin and
get it decoded back. What the drawing thread compares at pipeline misses and draws, the module's FNV-1a and its Binding
decorations, is kept with the shader, so module_binding_missing, the water corrections and the DLSS, crumbs and skip
lists decode nothing. A compute pipeline created at a dispatch and a mesh shader's first pipeline key still decode on
that thread, and the memory report counts those decodes apart, with the SPIR-V as held against uncompressed and the
shaders held compressed. shader_compress = 0 keeps it plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:55:56 -05:00
chrg6
97671e8ea7 Add the stutter map: each hitch of a play session with its area, what the frame waited for and the pipelines and shaders its draws met missing, and every 10 minutes and at exit the areas ranked by their hitches and the pipelines that cost the most (stutter_map = 1, off by default). tools/stutter_map.py turns a log into a report and counts what a game folder recorded that a pipeline list lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:31:57 -05:00
chrg6
cae09a2bcd Add floor_watch: hardware watchpoints on the floor the scan finds log which game code reads and writes it, at 60 fps and again at 120 fps or more.
floor_watch.c takes up to twelve of the floor scan's survivors (the platform's rigid body controller unit first, then one per object at the pivot's height), watches them four at a time through the debug registers on every thread (platform.h plat_watch_set, Windows only), counts each access's call chain in a lock-free table and logs the chains with the executable's symbol names after each pass. prof_syms gains a callback form for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:08:49 -05:00
chrg6
c84724da2c Make a moving floor carry Link above 100 fps as it does at 60 (set ofollow, floor_carry = 1).
The character controller's three follow-surface modules kept only a tenth of the floor's velocity whenever the physics' inverse step passed 100, which every update above 100 fps did. The gate now compares with 100 over frames per update from the patch page above 60 fps, and with the game's 100 at 60 and 30 fps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 07:08:49 -05:00
chrg6
a74503cd86 Add physics_steps = 2: at least a 60 fps frame's solver steps in every update on 1.1.1.
Link is carried by the Deku Tree's floating platform fully at 60 fps, about a tenth at 160 and not at all at 240,
which follows the solver steps an update (4, 2, 1 with physics_steps = 1) rather than those a second. The new set
osolverfull raises the rounded-up count to the 60 Hz frame's 4 in every update; physics_steps = 2 switches it on and
osolver off at start, and the mode is logged. 60 and 30 fps keep the game's 4 and 8. It costs more physics time above
60 fps: 576 solver steps a second at 144 fps and 960 at 240, against 288 and 240 with 1, the default, which stays
until a run confirms the carry. frame_step_check runs the lifted words of all three counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:26:39 -05:00
chrg6
b912d72b87 Remove the Video memory use row; its low behaviour is the only one now.
Released textures go once they have gone unused for the hold, and what a switched-off upscaler or SMAA held is freed, with no setting to keep them. A vram_use line left in a settings file is known and read by nothing. Memory use is to be decided by the card's and the system's budget instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:23:01 -05:00
chrg6
0e76baad36 Limit replay_small to 0..63, where it can match a group.
A group too small to share out has fewer than 64 draws (2 x CHUNK_DRAWS), so a threshold of 64 or more never matched
in group_start and the setting did nothing. The setting's range is now 0..63, the menu's slider ends at 60 in steps of
4, and a value of 64 or more from nx2navi.ini reads as 63 with one line in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 06:06:27 -05:00
chrg6
175033d49a Merge main's co-op switch name into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 05:54:58 -05:00
epicmeme546
a01910d905 Give the new co-op's switch a name of its own, so an old "coop = 1" turns nothing on.
The Steam co-op took the setting name of the old beta's switch, coop. A player whose nx2navi.ini still had coop = 1 from that beta got the new co-op switched on at the first start of the new build, unasked: the game signed in to Steam as its test app. The switch is now coop_steam (NX2NAVI_COOP_STEAM), off by default, and coop is a retired key like the old co-op's other settings: known, read by nothing.

--probe-cfg now loads a file of the old co-op and checks that its keys are no unknown keys, that coop = 1 leaves the new co-op off, and that coop_steam = 1 turns it on; it passes on a build of this commit. The game itself was not started with the change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 05:30:56 -05:00
chrg6
a7e79b3460 Bring the line's floor scan into the co-op branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:54:51 -05:00
chrg6
27dae195ab Arm the floor scan only on a sideways drift and keep what lies below Link, logged once a second.
Scan run 1 armed on a floor that only sinks and found points 3.4 m above Link that follow him (the camera's). The scan
now arms on a straight horizontal drift at a steady height, snapshots from 4 m below Link to 0.5 m above, keeps the
triplets that moved with him and lie more than 0.3 m below him or more than 0.5 m from his feet, and logs each of them
(64 at most) once a second with its move over that second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:45:52 -05:00
chrg6
da65aa6099 Bring public main's Steam co-op, the removal of the old co-op and the launcher's Steam library file into the 0.10.0 line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:37:01 -05:00
chrg6
2970b0c7d6 Add a floor scan that finds the floor carrying Link in guest memory on 1.1.1.
The Deku Tree's floating platform is not one of the engine's ground groups, and where its position lives is not known.
With floor_scan set (off by default), once Link has been carried at 60 fps for 30 frames with the left stick at rest,
one snapshot of the game's heap and alias regions keeps every float triplet near him; 30 frames later the triplets
that moved as far as he did are kept, and from then on each frame logs the twelve of them farthest from him with their
addresses, positions and moves. The snapshot logs how long it took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:36:29 -05:00