The user asked for it: the mode no longer gains frame rate, and the mesh shader fallback covers the cards it was written for. Its switch in the settings menu's Game tab is gone with the rows under it, and so are their 57 text ids and strings.
low_end_gpu itself does nothing any more. It is still a known key, so an older nx2navi.ini gives no unknown-key warning, and while it is on the log says once that the mode was removed in 0.10.2. The preset that gave nine keys other defaults is gone, and so is the rule that read the mode's keys only while it was on: each of them (mesh_emulation, tex_clobber, foliage, vram_heap_limit, vram_reserve_mb, upload_batch_mb, mirror_stage, mirror_hot, submit_finish_gpu and the others) is an ordinary setting with the default of its row or of its reader, so a line a player wrote by hand keeps counting and a file with only the switch runs the game as it runs for everyone else. guest_opt in nx2navi.ini counts for the next rebuild without the switch too (tools/make_game.py).
The recorder thread read the switch itself: on an AMD card without mesh shaders the mode turned it on. It is off on every AMD card now unless vk_recorder = 1 is set.
nx2navi.ini.example lists the former mode's settings as single settings, the 0.10.2 notes have a Removed entry and name the foliage fix of the commit before this one, and the comments that said what the mode sets say so in the past tense. tests/cfg_parse_check has the new reading of the switch and of the keys without it; the test matrix is at 90 ok with the same two not ok as before (installer_test, worktree_test), and a clean build compiles 169 runtime files and links. Not run in the game.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
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>
vk_sub_in_frame says which of its frame's submissions the one being recorded is; vk_memory.c sub_place files the mirror
pre-snapshot's statistics by it during the replay, on the replaying thread and its workers. vk_be_submit wrote it on
every submitting thread before submit_sync, outside sub_lock, so with the replay on the submitting threads another
thread entering a submit changed it under a replay in progress, and the replay filed its work at the other
submission's place. Each thread now notes the place in a thread-local (vk_be_submit, and replay_main for the
asynchronous replay), and vk_sync.c submit publishes it right after taking the lock for a submission with commands
(vk_sub_in_frame_publish, a weak no-op without the back end). Submissions without commands of their own (held work, the
present thread's blit) leave it as it was, as before. Performance heuristics only; no timing change.
Not reproduced before the change. Checks that passed: pass_cc_check on every runtime file (a header changed), no new
-Wall -Wextra warnings in vk_backend.c and vk_sync.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
vk_gpu_parts_log (trace=perf, a frame whose render thread waited 20 ms or more for fences) names the longest GPU pieces
by kind, "uploads" and "present" included. But its ring was filled only in ts_read_cb, with the replay's parts, mirror
prologues and pre-pass buffers. Upload batches (vk_memory.c up_ts_read) and presents (gfx_present.c pts_read) reach
vk_gtl_note_ns and never the ring. So the line could not show the ASTC decodes of the upload batches after an area
load, the frames it was made for.
The ring append moves into vk_gtl_note_ns, under gtl_lock, so every kind enters. Uploads are noted when their batch
retires and presents when their slot is used again, so the entries no longer come in GPU order. The reader copies
those that ended in the frame's interval and sorts them by GPU start before it computes the longest gap. PART_RING
grows from 512 to 2048, since an area load adds hundreds of upload entries and the reader takes the newest half. The
time "in the queue" is now counted from the hand-over to the queue for every kind, which for a replay part held by
the submit batching is the batch's hand-over rather than its submit call. The comment says so.
Diagnostics only (trace=perf). Checks that passed: pass_cc_check on vk_sync.c, no new -Wall -Wextra warnings,
sync_hw_check, stutter_hitch_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
completion_main calls vk_mem_collect after it publishes each new done, and vk_mem_collect waited for cmd_lock. An
upload flush holds that lock across vk_queue_lock, behind a present call (FIFO, a frame limiter inside the present,
frame generation) or another long queue-lock hold. With wait = 1 it also holds it across its batches' fence waits,
and stg_create waits for batch fences under it. The single completion thread blocked there could not process the next
timeline value, so the next done, and every fence waiter for it, waited for that chain.
vk_mem_collect_try only tries cmd_lock. When the lock is busy it still destroys the finalized pools, which need only
retire_lock, and leaves the deferred objects to a later completion or to the next upload flush's own collect. After
eight busy tries in a row it waits as vk_mem_collect does, which keeps the deferred list bounded, as before. pool_fini
keeps the waiting vk_mem_collect. The trace=perf "mem: sizes" GPU line counts the collects that gave way.
Not reproduced before the change: the completion thread's lock waits are not timed. Checks that passed:
pass_cc_check on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_memory.c and vk_sync.c,
sync_hw_check, gpa_check, mem_budget_check, tex_release_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the 2026-11-13 session frames of 60-96 ms came 2 to 6 frames after each area load with the render thread waiting 59-86 ms for the GPU, and no line said what the GPU was doing. With trace = perf such a frame now gets a line at the next present: the timed parts that ended in its interval, their GPU time, the longest gap between two, and the longest parts with their kind (draws, copies into VRAM mirrors, pre-passes, uploads, presents) and their time in the queue. The completion thread keeps the last parts in a ring as it reads their timestamps; nothing waits for the GPU. Measurement only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RX 6000 reports of 0.9.1 and 0.9.2 ran the completion callbacks of submissions the GPU never finished before any call returned VK_ERROR_DEVICE_LOST, although the completion thread polled the device first. Every value a queue hand-over or a host signal gives the submissions' timeline or the executed timeline is now noted before the signal (vk_sync_hw.h); the completion thread and the wait after a submit end the run when a timeline reads above that note. The first occlusion query found unwritten after its fence logs its submission, the submissions' values, both timelines' readings and the GPU breadcrumbs, and the lost device's report starts with the same line, so the next report shows whether the driver gave a value above the note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The driver's sleep sits where the game's frame starts: at its first pad read or window image acquire after a present,
whichever comes first, before the input is sampled. The frame's number, the count of the game's presents, goes with
its work: the guest thread marks the simulation and the first submission, the replaying thread tags its queue
submissions and marks their end, and the present thread marks the host present, which carries the number too. The
window's swapchain is made with the latency mode while NVIDIA's path is on, and remade when the setting changes.
While the mode is in effect the pacer holds nothing, so a picture goes out as soon as it is drawn and the limit is
kept by the driver before the pad is read; RivaTuner Statistics Server is kept out as with frame generation. The pacer
takes the limit back while a movie plays, while no host present goes out, and for the rest of the run when the frames
show the driver does not hold it. With the mode off every hook returns at its first check.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The game waits each frame until the GPU has finished the frame before. In Hyrule Field with FSR 4 the last of that
work is one part of 4.7 ms, the temporal upscaler and the interface drawn after it, then 1.4 ms of write-backs, and
what the game reads of the frame is done before the upscaler runs. With NX2NAVI_TAIL_EARLY the replay records that
tail into a command buffer of its own, and when it hands the CPU nothing but timestamps, writes no pool memory but
inline uniform updates of mirrored blocks and reads no guest memory late, the write-backs go to the queue before it
with the executed timeline's signal. The game's waits end there, and the GPU runs the tail while the next frame is
prepared. The GPU's commands are the same; a tail that fails a test goes as before. Off by default.
RX 7900 XT, Ryzen 7 5800X3D, 3440x1440, FSR 4 Native AA, one batch, replay workers off: 50.7 against 42.5 fps
standing and 56.0 against 45.9 along the field route; with FSR 3.1, 51.8 against 47.8 and 54.9 against 52.3. With
three replay workers, FSR 4, standing: 63.5 against 49.4. The screenshots matched those without it standing, along
the route and through warps to five areas, with no black frame outside the loading screens of both.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A registration the game made while a submission was being replayed changed the slot table under that submission,
whose texture pool set is written as the replay goes and read when it runs. Such a registration now waits and takes
effect once the submission has gone to the queue; with the replay on its own thread it goes into the replay queue
ahead of the next item. NX2NAVI_REG_ORDER=0 restores the old behaviour. In a town session the white kerb, stair and
wall-top edges of single frames were gone with it; the log counts the registrations that waited.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Mesh shaders as compute, the texture that gets memory of its own, staged mirror uploads and the shorter wait for the GPU, widened write-watch queries, the video memory limits and reserve, early upload batches, the asynchronous replay, the hot-pool rule and the recording thread on AMD cards without mesh shaders were measured on a Radeon RX 580 and run on no other card. Each keeps what 0.9.1 did unless the key or its own setting says otherwise; the key gives the defaults the way the device profile does. The notes describe them as that option, hold main's two new entries in one Changed section, and list the white edges in towns as a known issue.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The wait is for the frame's first submission; with staged mirror uploads it ended at that one's hand-over unless that
one itself read guest memory. The previous frame's submissions that read the grass and moss instances through the
device address table were often still on the GPU, and the game wrote the next frame's instances under them: the flicker
came back with every submit wait ending without the GPU (2220-frame run, RX 580).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each draw's indices are now a range of its own and its task records too, reserved by an allocation shader (one
invocation, mesh_emu_alloc_spirv) for the run of draw records queued: before the task stages the task records and the
index ranges of draws without a task stage, after them the index ranges of the others (mesh workgroups with output,
which the task stage now counts in the record, times OutputPrimitives x vertices per primitive). The mesh stage takes
its indices from the draw's range with an atomic in its record; vertex slots stay the arena's, as the indices name
them. So no draw's stages need another's, and the queue (MEQ) records the fills as the draws come, then at the
pre-pass command buffer's end (vk_sync.c pre_end -> vk_exec_mesh_flush): allocation, every task stage, allocation,
every mesh stage, a barrier between each. One by one each draw took three barriers and copied the arena's counters
after the draw before it: an RX 580 spent 5.5 ms of a frame's GPU time on 69 draws' stages in Kokiri Forest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 0.9.1 this is the release's own mirror capture (NX2NAVI_MIRROR_CAPTURE_KB) turned on: with
NX2NAVI_MIRROR_STAGE=1 (the default) the pages the CPU wrote are copied out of guest memory, up to 64 MiB a
part, when the part is handed to the queue. A submission whose prologue copied everything that way reads no
guest memory late, so the game's wait after a frame's first submission ends once the replay has submitted it
(vk_sync_recorded_wait). One with a range left to the GPU-time copy (a pool with a write-back in flight or a
twin) or a draw that reads a pool without a mirror is noted (vk_mem_note_live), and its wait is the GPU's.
Also 8 texture pool sets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The perf line's "idle with it queued" was the gap before each replay part handed over before the previous one ended:
13-17 ms a frame on an RX 580 in Kokiri Forest on 0.7.1, unexplained. Uploads and presents were not timed, a held
submission (submit batching) counted as handed over while it still waited in vk_sync, and with cuts and pre-pass
command buffers a submission closed more than the four timestamp pairs ts_unsent had room for: the rest went to the
completion queue without a submit time and out of the GPU's order. Now every timed piece of GPU work (replay parts,
mirror prologues, pre-pass command buffers, upload batches, presents) is noted with when it was handed to vk_sync, when
it really went to the queue and its GPU start and end, and every 60 frames two lines split the idle time: starved
(nothing in the queue, and how much of that a held submission waited in the batching) or with work in the queue, by
the kind of piece that started after the gap, with the average delay from hand-over to start on an idle GPU per kind.
ts_unsent has room for every command buffer of a submission, and the pairs are read in queue order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
queue_submit copies a held submission's command buffer infos into H.cbi[i], two entries each, the mirror prologue
and the part. Since the mesh emulation's pre-pass command buffers and cuts (vk_sync_pre, vk_sync_cut) a submission
carries up to three plus the cut parts, and the copy ran into the next held submission's entries and past the array:
the next held submission's copy then overwrote them, so a submission could go to the queue without its own last
command buffers and with another's twice. The buffer now has room for SUB_CBS (CHAIN_MAX + 3), and a larger count
stops the run with a message instead of overrunning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A split hands the part to the queue and seals its texture pool set; the next part takes another set of the ring and
refreshes its entries first. The splits for the mesh stages made 5 parts a submission and 6 set switches a frame at
2.3 ms of replay each (Kokiri Forest on 0.7.1, an RX 580: replay 32-38 ms a frame, the replay thread blocked on fences
26-35% of the time, 79 waits for a set the GPU still used). A cut (vk_sync_cut) ends the command buffer without
submitting it: the part and its pre-pass command buffer wait in a chain and go to the queue, in order, with the next
part that does, so the set is the same one throughout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each emulated mesh draw ended the pass, ran its stages between four barriers and began the pass again: a pass of 20
emulated draws became 20 passes, and on an RX 580 6 to 7 ms of a frame's 30 ms of GPU time went to the gaps between
passes (gpu_passes in Kokiri Forest: 8.7-10 ms per frame outside the game's passes and dispatches, 6.3-7.7 ms of it in
273-306 gaps with barriers only; 71 emulated draws a frame, 36 of them indirect). Now the first emulated draw of a pass
ends the part there (vk_exec_split_now), so the pass begins a new part, and the stages of every emulated draw of that
pass, the indirect ones' ring entries included, go into the part's pre-pass command buffer (vk_sync_pre), which the
queue runs after the mirror prologue and before the part; the pass is not split again. NX2NAVI_MESH_EMU_BATCH=0 (Lama
patches: Mesh emulation before the pass) brings the split back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A save load made thousands of textures between two replay submissions, and all of them went into one upload batch:
about 1.5 GB of raw ASTC blocks plus the decoder's work in a single submission, which AMD's 23.10 driver on an RX 580
refused with VK_ERROR_OUT_OF_DEVICE_MEMORY ("vk: upload submit failed (-2)"). The outermost vk_upload_end now submits
the open batch once its staging holds NX2NAVI_UPLOAD_BATCH_MB (256 by default; 0 = no limit). The batch still precedes
the replay submission that uses its textures, and its staging goes as it retires.
When a submit fails for lack of memory, vk_mem_oom_report logs each heap's use and budget as the driver counts them,
VMA's blocks and allocations there, the open and in-flight upload batches, the VRAM mirrors and pending releases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NX2NAVI_REPLAY_PIPE=1 (off by default) the submitting thread hands a group's chunks to the workers, takes chunks
of it itself until none is left to take, and goes on with the ops after the group in a new command buffer after the
chunks' places in the part (vk_sync_append_later), also into the next group's front pass.
Up to four groups are handed out and not in their part yet; their chunks wait in one queue in the order of the stream.
The submitting thread waits for them only where the order needs it: for room, before a part goes to the GPU, before
copies and texture or buffer clears, where DLAA's run or the contact shadows' hook acts, and where a lookup would make
a texture entry or take back a retired one (the op goes again afterwards, as a chunk's does in turn).
What the chunks read stays as the stream had it while the submitting thread runs ahead: uniform snapshot drops count
from their place in the stream, a chunk reads only the inline uniform updates before its group, the HDR present's
source goes by stream position, the texture pool set syncs under the chunks' lock, each group keeps its own render
conditions, occlusion segments and closed query slots, and the queries after a group's last report go into the next
segment made after it. A group's first chunk runs its pass hooks after the group before began its pass; turns wait
for every earlier chunk, of earlier groups too. Locks the chunks take, the submitting thread takes while it is behind.
Draws keep the sampled-texture descriptors they resolved per draw context and reuse them without the texture lock
while their slot is unchanged; the workers waited for that lock at nearly every draw. Texture lookups that change
nothing no longer wait for their turn. The perf line says where the submitting thread waited, what the chunks took,
and which texture lookups waited; NX2NAVI_GPU_TIMELINE also shows the chunks and the waits.
Off by default because it does not pay yet: in Hyrule Field with 3 workers it shortens the replay (11.5 against 12.1
ms a frame) but not the frame (49.2 against 49.6 fps in the same batch). The GPU gets a group's commands only once its
last chunk is recorded, so the main thread's wait for the GPU after the frame's first submission grew by 0.8 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chunks of a parallel group took query slots from the ring as they recorded, so the slots of the group's chunks
interleaved. The completion thread reads and resets slots in runs that follow the ring, and with interleaved slots it
made four times the result calls and a reset per slot: the wait after a frame's first submission ended 3.5 ms after
the GPU had run it instead of 0.6 ms, and the replay time the workers saved went into that wait (44 fps with 3 workers
as without). The front pass now gives each chunk a block of slots, as many as its draws that count samples and render,
in the order of the stream; a segment's slots are taken in ring order. Standing in Hyrule Field, 3 workers: 49.8 fps
against 43.7 with the workers off.
NX2NAVI_GPU_TIMELINE=<frame> writes four frames from that present on to the log: each replay, each timed part's submit
and GPU run, the waits for the GPU and the workers' groups, so a run shows whether the GPU waits for the replay, a wait,
or the CPU behind one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage 1 of recording a submission on several threads. A submission's draws come in runs between ops that have to
stay in the replay's order on its own thread (dispatches, copies, texture and buffer clears, events, the game's
barriers, calls). With NX2NAVI_REPLAY_WORKERS=1 a run of 16 draws or more is recorded by a worker thread: it takes a
copy of the replay state, records the run into a command buffer of its own pool with a draw context of its own, ends
it behind a full barrier and hands the state back. vk_sync_append puts that command buffer into the part between the
replay's own pieces before and after it, so the GPU runs everything in the op stream's order. The submitting thread
waits for each run, so every side effect keeps the replay's order; this stage checks that a run recorded apart from
a copy of the state gives the same pictures. The default, 0, replays as before.
walk's handling of the ops that do not steer it moves into op_one, which the worker uses too. NX2NAVI_REPLAY_RUNS=1
logs every 600 frames how the draws fall into such runs and which ops break them. The draw replay's hot functions load
their recording context once instead of at every use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
That wait ends when the GPU has run the submission's own commands (wb_split = 1). The completion thread was then still
at the last frame's submissions, so the occlusion counts, counter reports and event values the game reads next reached
its memory after it had gone on. A player's capture in Hyrule Field shows the result: trees, ground and walls lose
their direct light for single frames about every 36 frames, 57 of 1,800 frames, and with the fence's wait
(wb_split = 0) the same place showed none.
The fence's wait also covers the rest of what the completion thread does for those submissions, 0.23 to 0.40 ms a
frame of page stamps the game does not read. So those results now have a queue of their own
(vk_completion_add_result), and the waiter runs the ones due for its submission and the ones before it as soon as
the GPU's signal is there; the completion thread runs them the same way, ahead of its other callbacks, one runner at
a time and in the order they were queued. wb_split = 3 is the bare wait as it was, to measure against.
Checked: it builds, the launcher tests and the address table check pass, and the player's hand test of this wait at
that place showed no such frame. Not measured: no scripted run of it was made (the one scripted run with the bare
wait in that field shows 1,170 occlusion counts a frame written late and no dark frame, so the script does not
reproduce the fault), and its cost against the bare wait is not known.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The author of pull request #353 closed it on 2026-11-11 with the words that they give no permission to use its code. Its port (3134f524) goes: the mark of a pipeline that declares the table (VkPipeInfo.gpa, vk_pipeline.c pipe_add) and of the part that draws with it (vk_mem_gpa_use in vk_exec_draw.c bind_resources and mo_emit), the sync of the table's pools in every such part's prologue (vk_mem_gpa_sync), the submission with a callback in the replay's place as it stood (vk_sync_probe_submit), and --probe-vkmem's read through the table with its shader (test_gpa_read, runtime/shaders/gpa_read.spvasm).
In its place, after pull request #551, which was written without that code: a shadow pool goes into the table and onto a list of the table's shadows (vk_memory.c shadow_table_add); the record of a binding of the table notes the guest submission (vk_exec_draw.c ssbo_info, vk_mem_shadow_table_read); and vk_mem_shadow_sync brings the CPU's writes into every shadow on the list once for that guest submission, before the part is submitted. Once is enough, because the copy-ins read the guest submission's write-watch snapshots, which a second sync would find unchanged. Measured by #551's author on Linux (RTX 4090, Kokiri Forest): 113 and 76 frames over 25 ms in 75 s against 217 and 429 with the port, 43 against 41 fps, the picture complete in both.
What this line had built on the port stays, on the new sync: the test that passes over a pool with nothing written (shadow_pool_clean), the upload of the same pages into a table pool's VRAM mirror, the write-back of stores through the table (NX2NAVI_SHADOW_TABLE_STORES, table_wb_cb, now asked once per guest submission that binds the table) and the counts of NX2NAVI_SHADOW_CHECK. The probes that record their own commands (the lost-device probe, --probe-vkmem's table parts) call vk_sync_submit_own, written anew. vk_mem_shadow_storage stays: one line that returns the mode. Finalized pools in the table were already kept for vk_mem_shutdown here.
--probe-vkmem has a check of its own for the sync (test_table_sync): the shadow of a pool that is never bound keeps its old word through a CPU write and a guest submission that does not bind the table, and holds the new one after a guest submission that does. It passes with NX2NAVI_SHADOW=1 on Windows (RTX 5090), as do the store check with NX2NAVI_SHADOW_TABLE_STORES=1 and the probe without shadow storage; --probe-devlost ends as before. Not run: the game, with or without shadow storage, and anything on Linux.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The fence's wait cost about 4.5 % of the frames a second, and the player who reported the flicker still sees it and black frames with it. wb_split = 0 keeps the fence's wait for whoever wants to compare.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With shadow storage a write-back at a fence copies a page that has no base copy whole from the shadow into guest
memory. Every write the replay records gets base copies, so such a page means the pool the write was noted on was
finalized before the fence and another was made at its address: the new record's shadow is a copy of guest memory
from its own start, holds no byte of that write, and went over everything the CPU had written to the page since.
The page is now left alone when the record is younger than the submission whose fence it is, or when the CPU has
written the page since the shadow's last copy-in; the shadow line counts such pages.
--probe-vkmem has the case: with NX2NAVI_SHADOW=1 it fails without this change (0x11 over the CPU's 0x22, with
imports and with mirrors) and passes with it, as does the run without shadow storage.
Whether a game does finalize and remake a pool inside that window was not observed in play.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
For a day that wait ended on a second timeline, when the GPU had run the submission's own commands (wb_split = 1,
then the default), on the thought that it only left out write-backs nothing needed yet. It left out the completion
thread's side effects for the submissions before it as well, and the game reads some of those next: the occlusion
counts, counter reports and event values, and the stamps the texture checks read. A frame's first submission is small
and runs right behind the last frame's large one, so the game went on while that one's callbacks were still running.
Measured in Hyrule Field with the camera turning (RTX 5090, lazy write-back on): 2,267 of 2,320 such waits returned
before those side effects had run; they were through 0.45 to 0.8 ms later on average, 12.6 ms at most, and in that
time 770 to 1,015 occlusion counts, 21 to 28 counter reports and 17 event values a frame reached guest memory. The
submission the game waits for wrote nothing back in any of 14 runs, so its own write-backs were never the matter.
A player saw a patch of grass near the camera change from frame to frame there and less of it with the fence's wait.
No scripted run showed such a frame with either wait, so the link to what the player saw is not shown.
The fence's wait takes 0.65 to 0.7 ms more of a frame there, about 6 % at 85 frames a second (single runs beside
other work). wb_split = 1 still gives the other wait, and 2 forces it with shadow storage.
NX2NAVI_TRACE=perf now counts what a wait on that timeline leaves behind and the completion thread's time by callback.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
(cherry picked from commit 4d108b706e72c001ddbe20608f06469885df584b)
A lost device used to end the process from whichever submit or wait noticed it first, while the present only logged it
and went on and the other threads kept calling the driver. The completion thread could take the submissions' timeline
as reached and run the callbacks of submissions that never finished.
vk_device_lost_check now takes the result of every submit, present, acquire and wait. The first thread to see the loss
sets a flag, writes the report and ends the process as a fatal error does (the log written, a save commit under way
finished), with an exit code of its own; later threads stop in that call, and a watchdog ends the process after ten
seconds should the first thread hang. The replay, its submit, the uploads, the present and the lazy fetch read the flag
and return without touching the device. The completion thread asks the device with a call of its own before a new
value counts and runs no callback once the flag is set.
The executable's --probe-devlost and tests/device_lost_check.py check this without a lost device, and
device_lost_at in nx2navi.ini ends a real run the same way.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Verified by runs in the audit: the Market's periodic stall (a pool slot was listed once per refresh pass instead of once; the list fell from 160,385 entries to at most 3,192 and no frame over 20 ms remained while standing), orderly exits delete the pipeline prebuild's running marker (left behind, the next start wrote a skip file and every later start skipped the prebuild), the log and crash report name an area a save asks for that the game does not have, and the menu atlas hash no longer overflows a signed multiply. The other fixes compile and pass the regression route but their faults were never reproduced: see AUDIT.md section 13 for each.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The two sides both made the wait after a frame's first submission cover the write-backs under shadow storage; the rule is kept in one place, vk_sync_wb_split.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
NX2NAVI_SHADOW_CHECK=1 logs with the shadow line, every 60 frames: the whole copy-ins that overwrote a store a
shader made through the device address table, and the pools they fell on; the write-backs that moved bytes into
a page the CPU had written since its copy-in; the bytes both sides changed since their base copy; what the
copy-ins cost by kind, and which pools and flag sets took them. The stores are found by content: a whole
copy-in leaves a hash of the page, and the next one compares the shadow page with it first.
NX2NAVI_SHADOW_TABLE_STORES=1 keeps such stores: a page found stored into gets a base copy, so copy-ins take
only the CPU's bytes, and the fence of every submission that uses the table writes the stores back to guest
memory. --probe-vkmem checks it with a GPU write that nothing notes. It is off by default. The overwrite it
removes was the suspected cause of the flicker on Linux and is not: standing outdoors (RTX 5090 with
NX2NAVI_SHADOW=1) it happens exactly four times a frame, on ten pages in pools of 20 and 44 KiB, with the copy
that precedes the frame's stores, and no frame came out wrong with it or without it in 1,800 frames each; the
hashes cost 1.8 ms a frame of the replay (67 against 75 fps); and a count the CPU resets before the write-back
has landed would keep counting, where the whole copy-in resets it every time.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With shadow storage (the Linux default) what the GPU wrote reaches guest memory in the fence's completion
callback, later than an import's store on the GPU. The wait after a frame's first submission returned at the
executed timeline, before that, so the game rewrote what those passes had written and the write-back then put
the GPU's older bytes back over the rewrite. The whole 3D scene came out brighter for single frames, each of
them a frame whose main work was held at its CPU event wait and replayed late.
Standing outdoors with NX2NAVI_SHADOW=1 on an RTX 5090: 695, 624, 443, 323 and 2 flashed frames of 1,800 in
five runs with the split, none in eight runs without it. The frame rate did not drop (74.6 fps median with the
fix; runs of the build before it gave 71 to 72). NX2NAVI_WB_SPLIT=2 keeps the split with shadow storage, to
measure the fault. That this is the flicker a player reported on Linux (RX 7800 XT, RADV) is not confirmed on
their machine; NX2NAVI_WB_SPLIT=0 gives 0.7.1 the same wait there.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With shadow storage (the default on Linux and under Wine) what the GPU wrote reaches the game's memory in the completion thread's fence callback, after the executed timeline that the split wait returns at. The game could then rewrite a pool, and an older write-back overwrote what it wrote: in Kokiri Forest a sky coefficient the game had reset came back, and the world flickered and looked washed out. The wait now covers the write-backs whenever shadow storage is in use; with imported or mirrored pools, the Windows default, nothing changes. The contributor's RX 9070 XT under RADV no longer showed the flicker.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Four "mem: sizes" lines give the entries and bytes of what no other line counted: the guest heap, physical maps, kernel region, lazily written ranges and the fixed kernel, setjmp, save-file, RomFS and zstd tables; pool records, mirrors, deferred destructions, upload and staging buffers, the device address table and the texture, sampler and slot caches; image views, descriptor spares, the completion queue, uniform snapshot arenas, recording copies and pins; and pipelines, layouts, stage libraries, job queues, the shader memory cache and the translator processes. Each subsystem reads its own counters under its lock; settings now count the replaced values they keep. Segment and scene lines say "-" for the world clock before it starts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result of that read was not checked. After a device loss the value was taken as it came, the callbacks of submissions that never ran were called as if their work were done, and the game went on recording until the next checked queue call failed. That was an upload's, so two players' reports read as an upload fault.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With shadow storage (Linux, and Windows builds under Wine) a CPU-visible pool is a host-visible copy with
no memory object of its own, and only pools that owned one went into the device address table: pointers
the game's shaders read from memory reached nothing. Shadow copies are now in the table. A shader that
reads through the table resolves no pool, so nothing queued the pool it reaches for the copy-in: the part
is marked where its pipeline is drawn or dispatched (VkPipeInfo.gpa, vk_mem_gpa_use), and its prologue
brings the shadow of every pool in the table up to date with the CPU's writes and queues the same pages
for those pools' VRAM mirrors (vk_memory.c vk_mem_gpa_sync). --probe-vkmem reads a word through the table
from a pool that is never bound, before and after a plain CPU write.
Pull request #353 by TophC7, ported onto the base copies of the black-world fix:
- taken as it is: the table registration, the mark in vk_exec_draw.c and vk_pipeline.c, the prologue
call and vk_sync_probe_submit in vk_sync.c (two hunks merged by hand), the probe's test;
- written again: vk_mem_gpa_sync, on top of the snapshot-based shadow_sync_pool; a lookup no longer
queues a pool for being in the table, which every shadow pool now is;
- narrowed: the sync runs only with shadow storage. Without it (Windows) a mirrored pool stays unsynced
for the table's readers as before, so the frame there is unchanged, and the probe makes the source's
mirror only with shadow storage and skips when the device has no address table;
- the probe's shader was an array of SPIR-V words in tex_probe.c; it is now the source
runtime/shaders/gpa_read.spvasm, built like the other runtime shaders;
- the exact re-upload count in the texture probe stays checked on Windows.
Files other sessions are changing: runtime/vk_memory.c (vk_pool_init's registration, mem_resolve's touch,
the new section before vk_mem_use).
The Windows files compile and the shader assembles and validates; no probe was run, and nothing was run on
Linux here.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A review found a gap: a part checked what it had skipped at its end, and a fault between that check and the part's
hand-over to the queue fetched without the part's writes while nothing wrote them back afterwards. The replay now
opens a window from the check to the hand-over, per part, and a fault on an armed page waits for it to close (at most
20 ms; the replay thread's own faults and thaws do not wait and are counted). What a part skipped for a range that is
no longer armed goes back through the usual snapshot and merge, taken in that part's prologue, and not as a plain
copy, which would have put the mirror's words over what the CPU had just written there. A part split off earlier
makes the same check before its prologue. wb_lazy_touch is a test that reads armed ranges on purpose.
Dev build, Hyrule Field: 71.1 fps as before; standing, a fight, pause and unpause, and the touch test made 146 to 338
faults each, none inside a window, no fault of its own.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every frame the render thread waited about 5 ms for the GPU after the frame's first submission, with the game
CPU-bound in every area. The wait is there so that the GPU has run that submission's passes before the game
rewrites what they read. But the submission also ends with the write-backs of what its shaders wrote, 60 MiB merged
into guest memory in 3 to 4 ms of GPU time, and the wait covered those too.
The write-backs now go into a part of their own. The part before them signals a second timeline, the executed
timeline, and the first-submission wait waits on that; the write-back part signals the timeline as before, so every
fence the game asks for still covers them, in the same queue order. wb_split = 0 keeps one part and the old wait.
Not measured in the game yet.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Generation is switched off for every menu, cutscene and frame without depth and motion, and on again after it. Off freed the generator's resources and on allocated them again inside the present, which holds the queue lock for its whole flip, so the submitting thread waited there: four of five slow frames profiled in a session with frame generation on had the log line "frame generation on" right before them and nothing else blocked. nx2navi_dlssg.dll now sets Streamline's retain flag, so off only suspends; whether frame generation runs is fixed for the run, and Streamline frees the resources at shutdown. The DLL has to be rebuilt (tools/build_dlssg.py).
The wait for the queue lock in vk_sync.c queue_submit is counted as a present wait, so a frame held up by a slow present no longer shows in the slow-frame line with every blocked time at zero.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
(cherry picked from commit 94a0da9f7694cfe4d65777f9efe8eff837ba2bc8)
A session with per-pass GPU timestamps showed the game's own render passes and compute dispatches at 1.6 to 2.3 ms of GPU time a frame while the perf line's GPU time was 7.7 to 13.5 ms: most of the frame's GPU time is in commands the replay records between the marks, and nothing said which.
The timed frames now also get a timestamp at the start and the end of each part of a submission, and every stretch between two marks (or a mark and the part's edge) is a gap. The replay names the kind of commands it records in a gap: buffer and texture copies, fills and clears, the temporal upscaler, write-back merges and copies, depth twin copies, the texture pool marks, the mesh draw counts and block-compressed target copies, each with a size where it has one. The report adds the gaps' time per frame summed by the kinds noted in them (a gap nobody noted holds barriers and state only), the parts' own time for comparison with the perf line, and each kind's count and size per frame. Nothing runs outside the timed frames.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
lock_time (NX2NAVI_LOCK_TIME) makes the replay threads time each lock they have to wait for, kept per asking site (platform.h plat_lock_timing, plat_mutex_lock_at and plat_rmutex_lock_at carry the site), and the frame-time summary lists the sites with the most wait, the recorder's full-ring and drain waits, and the texture lock's holds over 1 ms with the code that took it. replay_time now also keeps each frame's replay ops: a frame over 25 ms charged to the replay says how many draws, binds and transfers it replayed, their time, its slowest op, and how much of the replay's wall time the thread was on the CPU. NX2NAVI_PROFILE_REPLAY=1 samples only the replay thread without call chains, and the profiler now follows the thread that replays instead of the one that started the back end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>