The review found that memory.c mem_lazy_skip's loop over the ranges armed for another buffer waited for a free entry
in the flight table before it looked for one. On its last pass, which finds nothing left to take, the replay could
still wait for another thread's fetch to end when the table was full. Now it looks first, and waits for room only when
it has a range to take and every entry is in use, then looks again. The search is lazy_foreign_locked, shared with the
check before the loop. Not reproduced before the change; lazy_fetch_check passed in all its scenarios (foreign and
flood among them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that the comments on frz_lock (memory.c, above FRZ_SHIFT), lazy_fly_run and vk_memory.c lazy_fetch
said a fetch runs under frz_lock only in a mapping change's thaw or with NX2NAVI_WB_LAZY_UNLOCKED=0. In fact
thaw_begin_locked's lazy_drop_region_locked runs in every thaw under the lock: a mapping change, the lazy heap's commit,
and thaw_fault on a write lazy_fault did not take. lazy_drop_region_locked's own comment said "a write fault in a
frozen region", but a frozen region holds no armed ranges. And the LZ_SLOTS comment called four slots more than the
threads that touch armed pages together, where the reference session had six such threads and the flight table holds
64. The comments now give that list of thaws, and LZ_SLOTS says that fetches past it wait 20 us at a time for a slot.
Comments only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found two windows in memory.c lazy_fault, where a fault holding frz_lock and lz_lock finds its address
neither armed nor in flight. It returned 0 there, and access_fault then asked for the page's state without a lock.
A write whose range a flight gave back while the write waited for frz_lock went on to thaw_fault, whose thaw released
every armed range of the 2 MiB region (lazy_drop_region_locked), each fetched under the lock. And after a release
that sets no hold (mem_lazy_skip's give-back of another buffer's range), the completion thread could arm the page
again between that 0 and access_fault's query, so a read saw an inaccessible page and went to the crash handler; that
one predates the ranges in flight.
Now a fault that finds a flight there waits for it as before. Otherwise it releases lz_lock, asks for the page's state
while it still holds frz_lock, under which no arm runs, and runs the access again when the page allows it: a read on
a readable page, a write on a read-write one. A write to a frozen page still goes on to thaw_fault, and an address
that is not ours to the crash handler.
The new late_write scenario of tests/lazy_fetch_check.py has a thread write a range whose release starts and ends
while the write waits for frz_lock: the write runs again and the region's other range stays armed. With the old
return of 0 it fails, the other range released. The read's window has no check: the arm would have to land between
two instructions. Not reproduced in the game before the change; lazy_fetch_check passed in all its scenarios.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the setting at 0, frz_lock was held across every fault's and release's fetch again and mem_thaw released under the
lock, but two parts of the new code stayed. vk_memory.c mirror_evict and hot_check still fetched their victims'
armed ranges before taking pool_lock again, and a release still put up to 16 copies in one submission. Those are the
parts that run only with a device and have no check without one, so a device loss caused by them was the one case the
switch could not take back. Now vk_memory.c reads the setting too (evict_unlocked, once on the replay thread, where
both run): at 0 neither drops pool_lock, and the release under it fetches everything, victim by victim, as before. And
memory.c lazy_take_batch_locked takes one segment at a time at 0, so each is fetched on its own.
What stays at 0 is the table of ranges in flight: a touch of a range being fetched waits for that fetch and runs
again (a write no longer goes on to thaw_fault and releases its region), the hold is set as the range goes out, and
mem_lazy_touches counts the range. memory.c "Ranges in flight", nx2navi.ini.example and docs/building.md say so.
tests/lazy_fetch_check.py gains locked_release, the release scenario at 0: three fetches of one copy each, where the
batched release makes two of up to two copies. It fails with the cap taken out. The eviction's side has a compile
check only. Not reproduced before the change; lazy_fetch_check passed in all its scenarios.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CPU touch of an armed range ran its fetch (vk_memory.c lazy_fetch: a GPU copy after everything handed over, and a wait of up to 10 s) inside the fault handler under frz_lock. Every other fault, thaw, freeze and arm waited for the whole copy meanwhile: the replay's copies of guest memory (mem_copy_readable, vk_memory.c mirror_capture) and its freeze pass, the completion thread's arms, file reads and pool finalizes. A write that lost the race to the fetch went to thaw_fault after it and released every other armed range of its 2 MiB region, a second fetch. mirror_evict and hot_check fetched their victims' ranges under the exclusive pool_lock, which the completion thread's arms and every pool lookup wait for. This is AUDIT F-4-05 (C-12); the design's probe of the unchanged code had an unrelated mem_copy_readable, arm and freeze each wait 299 ms behind a 300 ms fetch, and the losing write 378.8 ms with two fetches.
A range now leaves the armed list into a table of ranges in flight (memory.c "Ranges in flight", lz_fly) in the same lz_lock hold, and stays there, its pages inaccessible, until its bytes are down and its pages read-write again (lazy_fly_run), without frz_lock. A fault on a range in flight waits for that entry alone on lz_lock's condition and runs the access again, the losing write included; a thaw, a mapping change, a release of its buffer, an arm, a freeze and mem_lazy_touches see it. Every store to lzr is made under lz_lock from the list, the entries and the region an arm is protecting (lz_arm_r, which replaces the previous commit's restore). An entry starts under frz_lock and lz_lock and ends under lz_lock alone, so a holder of frz_lock may wait for one: its fetch takes only the queue lock, which no frz_lock taker holds. mem_thaw releases what is armed in its regions before it takes frz_lock (lazy_release_regions), so file reads and pool finalizes fetch without it; a mapping change's thaw still fetches under it, as its page calls follow. The holds a fault sets (lz_hot, lz_hots) are now set as the range goes into flight, so an arm meanwhile finds them. The fetch takes several copies in one submission and one wait (MemLazyCopy), and the back end has four slots of command pool, command buffer and fence, so fetches of different ranges run at once; a release takes up to 16 armed ranges out together.
mirror_evict and hot_check now fetch what only their victims' mirrors hold before taking pool_lock exclusively, and then, under the lock, release only what was armed since (usually nothing) before the mirror leaves the list and the device address table names the imported buffer again. The design moved the release after the lock instead, which would have had the table change before the fetch: a shader of a submission still running reads through the table as it changes, and the fetch waits for every submission handed over. A victim that vk_pool_fini took off the list meanwhile is left to it; hot_check copies its log line's fields under the lock, as its records may be freed once the lock drops.
The new setting wb_lazy_unlocked (NX2NAVI_WB_LAZY_UNLOCKED, default 1, restart) set to 0 keeps frz_lock from the take-out to the end of each flight and leaves mem_thaw's releases to the thaw, as in 0.10.1; the table, the slots and the eviction order stay. The "mem: lazy write-back" line counts segments fetched; a second line gives the touches that waited for a range in flight, the longest such wait and the most ranges in flight at once; "mem: sizes" counts fetches that found every slot busy. The frz_lock and lz_lock comments say what holds what now (B-12). mem_lazy_touches has callers outside memory.c (camera_speed.c, nvn_api_cmd.c ctl_fingerprint), which now skip a range in flight instead of faulting on it and waiting.
Not reproduced in the game before the change. tests\lazy_fetch_check.py now runs 20 scenarios as their own processes: arm races, no stall of freezes, copies, arms, thaws and releases of other ranges during a fetch, joins with frz_lock held elsewhere, a losing write that keeps the region's other range armed, two fetches at once, a thaw, a mapping change and a release meeting a range in flight, an arm refused over one, the foreign give-back, the chunk mode, the old wait with wb_lazy_unlocked = 0 (joins and losing writes as well), the report lines, 80 touching threads past the 64 entries, and a freeze refused; all pass, in six runs. Taking the join before frz_lock out fails the join scenario. cfg_rows_check reports 0 problems; every runtime file compiles, and memory.c and vk_memory.c add no -Wall -Wextra warning; the test matrix passes 75 of 77, installer_test and worktree_test failing for the environment as before. vk_memory.c's slots and the eviction's order have no check without a device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fetch of lazily written pages runs inside the fault handler under frz_lock (memory.c lazy_gone_locked and lazy_piece_fetch_locked call vk_memory.c lazy_fetch, a GPU copy after everything handed over and a wait for it), so every other fault, thaw, freeze and arm waits for the whole copy. The line gave only the fetches' sum: the 0.10.0 session's 848 fetches took 415.8 ms in all, and how long one took, or how long another thread waited behind it, was not known. Both fetch sites now keep the longest fetch, and lazy_fault keeps the longest time a fault waited to take frz_lock; the report line prints them as "the longest X ms, faults waited up to Y ms for the freeze lock" and starts both over. No tool parses the line.
tests\lazy_fetch_check.py's new report scenario fetches for 30 and 120 ms and has a third fault wait 60 ms for frz_lock: the line reads 120.29 ms and 60.03 ms, and the next one 0.00 for both. arm_race, arm_race_slow, wb_lazy_check and heap_lazy_copy_check passed; memory.c compiles without a warning under -Wall -Wextra.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mem_lazy_arm made a range's pages inaccessible (lazy_protect_locked: the protect, then the back end's clean test) and set the region's lzr mark only after the segment was in the list. lazy_fault returns 0 at once for an unmarked region, without taking frz_lock, so a CPU read in that window found the page inaccessible in access_fault and went to the crash handler as an access violation. A write survived only because thaw_fault waits for frz_lock. Arms come from the completion thread (vk_memory.c vk_gpu_written_back) when a submission's fence completes, which is when the game's own fence wait returns and it reads the results; the window lasts as long as the protect and the clean test, which can take tens of milliseconds while the driver pins memory.
The mark is now set before the protect, after the cheap refusals, and put back when the protect or the clean test refuses the arm. A fault from the protect on finds the mark, waits for frz_lock and then finds the segment, or the pages given back. Every store to lzr is made under frz_lock, which the arm holds, so putting it back is exact.
Not reproduced in the game before the change. The new tests\lazy_fetch_check.py reproduces it with the real Windows fault handler: a thread reads a page while it is armed and given back 20,000 times (arm_race: an access violation in 5 of 6 runs before the change), and once with a clean test of 100 ms (arm_race_slow: an access violation in every run). Both pass in every run since. wb_lazy_check, heap_lazy_copy_check, freeze_hold_check and mem_growth_check passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NX2NAVI_WB_LAZY_CHUNK=1 (off by default), lazy_fault's piece branch called lazy_piece_locked with lz_lock held.
lazy_piece_locked fetched the piece (vk_memory.c lazy_fetch: a submit under the queue lock, then a fence wait of up to
10 s) and made its pages read-write, all under lz_lock. memory.c's own rule says lz_lock is never held across a system
call or a fetch. The replay asks that lock at every draw that writes a mirrored pool (mem_lazy_skip) and at the end of
every part (mem_lazy_window, mem_lazy_armed), so it waited for the whole fetch.
lazy_piece_locked is split in two. lazy_piece_take_locked runs with lz_lock held: the piece leaves the list and the
segment's rest on either side goes back. lazy_piece_fetch_locked runs with frz_lock only: the fetch, the page calls,
the region flags and the hold. lazy_fault calls the first, drops lz_lock, then calls the second, as its whole-segment
branch does. A second fault on the same piece waits for frz_lock, finds no segment once the fetch and the reprotect
are done, and access_fault retries the access, as with the segment path.
Not reproduced before the change: chunk mode is off by default and no check without a device covers the fetch. Checks
that passed: pass_cc_check on memory.c, no new -Wall -Wextra warnings, wb_lazy_check, heap_lazy_copy_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment above FRZ_SHIFT called frz_lock a leaf lock. But lz_lock and vm_lock are taken inside it, a strict
thaw's VirtualProtect calls run under it (thaw_pages_vm), and lazy_gone_locked and lazy_piece_locked call
vk_memory.c lazy_fetch under it. lazy_fetch takes the queue lock, submits, and waits up to 10 s for the GPU. The
comment now says so, and states the rule that keeps the order frz_lock before the queue lock free of an inversion: no
holder of the queue lock touches guest memory or takes frz_lock. The audit checked that no holder of the queue lock
does.
A comment only. Checks that passed: pass_cc_check on memory.c, check_content --staged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NX2NAVI_HEAP_LAZY=1, a heap page nothing has touched yet is still reserved. mem_copy_readable holds frz_lock
and, for a range with no armed or probed region, copied it with a plain memcpy. A reserved heap page in that range
faulted into fault_veh, then access_fault, then heap_lazy_fault, which takes frz_lock on the same thread. The SRW lock
is not recursive, so the copy never returned, and every later fault, thaw and freeze waited behind it. Callers are
frame_rate.c's floor probe and vk_memory.c mirror_capture (MIRROR_CAPTURE_KB or MIRROR_STAGE).
Now, when the lazy heap is on and a range meets the heap, the range is copied run by run as plat_mem_state reports its
pages. The heap's reserved runs read zero, as they would once committed, and are not committed, so a diagnostic read
or a mirror's first copy does not undo what the setting saves. Readable runs are copied, and anything else leaves the
range out (ok = 0). Ranges outside the heap, and every range while the lazy heap is off, keep the copy that asks the
system nothing.
tests/heap_lazy_copy_check builds memory.c with the Windows platform layer and its fault handler. It copies an
untouched heap page, a range from written pages into untouched ones, a range past the heap's end, and a batch of
three, each on a worker the main thread gives 3 s. Before the change the first copy hung (exit 4). Checks that passed:
heap_lazy_copy_check, pass_cc_check on memory.c, no new -Wall -Wextra warnings, wb_lazy_check, mem_growth_check,
mem_budget_check and gpa_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Every VMA allocation is counted under the file that made it until it is freed, so the new "mem: by owner" line
splits the device-local memory the driver reports into textures (live, GPU-drawn, idle, released, released too
lately to drop), pools and VRAM mirrors, upscalers, replay buffers, uniform snapshots, the device address table,
the ASTC decoder, present and post-processing, headroom spares, the free space inside VMA's blocks and what VMA
never allocated. "mem: host" gives the host-visible allocations, the process's commit and working set and the
game's heap. "vk: released textures" counts take-backs by how long the texture was released and textures made
again after a drop by a bound or a pressure, with the drop's reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The heap grew by the full-picture factor whenever the upscaler setting was on, in every mode, and without a limit:
915 MiB at 3840x2160 where the modes below three quarters need 382, and at 10240x2880 an application pool of 13.5 GiB,
past the 12 GiB heap region, where the game's SetHeapSize is refused and nothing starts.
The share is now the start's own mode: all of the picture for dlaa, the game's own most for the modes below it. The
growth stops at what the pool can take (2,478 MiB): the scene's share is lowered to what fits, with a log line, and a
picture too large even for the game's own scene gets the most there is and a warning. A mode chosen in the menu that
renders more than the start's keeps the start's share until a restart, as the log and nx2navi.ini.example now say.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Conflicts: the settings key lists take both sides; frame generation keeps the camera-cut handling and logs its counters every 600 frames with the cuts among them; the pass-time mark moves into draw_viewport; mo_desc keeps the pose depth and notes the reader.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
At every submission that binds the texture pools the replay checks each listed texture for CPU writes, and takes the write-watch snapshot of the 2 MiB regions that have none yet as the list comes to them: one query a region, since the list is in no address order. Standing still that is 5 to 10 queries and the check takes 0.11 ms. While the player runs through Kakariko or Castle Town the game writes about 32 textures into new storage every four or five frames, a region the CPU wrote is not frozen again for 1,800 submissions, and the check asked for 100 to 500 regions one by one, 2.7 times a frame: 0.5 to 1.5 ms a check, about 3 us a query, in five sessions of one player. The regions are now collected for the whole list first and asked in one query per run of adjacent regions. On this machine 400 queries of 2 MiB take 727 us and one query over the same 800 MiB takes 360 us. The same regions are queried and the same pages stamped, all of them before the list is looked at, as each entry's were before its own look; a run across the edge of a watched reservation is left to the queries by range. pool_presnap_runs = 0 asks region by region as before.
A released entry stays in the list until it is destroyed, the check does nothing for it, and the stamp it compares stays as it was at the release, so one whose region took a write later missed the region check at every submission from then on and was fetched only to be passed by: 1,000 to 6,300 entries a check, with up to 8,192 released entries kept. The list now carries the released state, set at the release and cleared at the take-back, and the check passes such an entry by without touching it or asking for its regions.
The pool log's freshness line counts the released entries passed by, and its snapshot queries count the queries either way. --probe-vkmem has a case for the queries by run.
Co-Authored-By: Claude Fable 5.2 <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 prologue of a part copies the pages the CPU wrote since a mirror's last sync from guest memory into the mirror when
the GPU runs it, a frame or more after the game's submit when the GPU is behind: the GPU then reads what the game
wrote there meanwhile, values meant for a later frame. With NX2NAVI_MIRROR_CAPTURE_KB=n the CPU copies up to n KiB of
those pages per part into a host buffer of the submission when the part is handed over, and the prologue copies them
from there. A pool keeps the GPU-time copy while a write-back of its mirror into guest memory is still on the GPU (its
fence, now kept per pool), when it has a twin (the device address table's writes reach guest memory unrecorded), and
for a range with a page that cannot be read without a fault (memory.c mem_copy_readable, under the freeze lock, so a
lazily armed range stays with the GPU). With the default of every mirror twinned (NX2NAVI_GPA_TWIN_MAX_KB=0) it
copies nothing; it is a test switch for the replay's late reads, default 0.
Measured on an RTX 5090 with NX2NAVI_GPA_TWIN_MAX_KB=64 (mirrors of pools over 64 KiB without a twin), 75 s runs
with warm caches, NX2NAVI_MIRROR_CAPTURE_KB 0 against 8192: Kakariko 0.95 MiB a frame copied in 149 ranges for
0.21 ms of the replay thread, 116.7 against 118.4 fps (interval median 6.26 against 6.46 ms); Hyrule Field walk
1.38 MiB in 224 ranges, 0.37 ms, 110.2 against 107.2 fps, 1% low 83.3 against 78.1. 0.08 MiB a frame stayed with the
GPU-time copy. A first version that took the freeze lock and asked the system for each range's pages cost 2.9 to 4.3
ms a frame. A Kakariko run with NX2NAVI_REPLAY_ASYNC=1 and the copy on played through without errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write-back finishing on the completion thread could arm its range between a mirror's eviction and the drop of its
buffer, naming the freed buffer; a later write into the new mirror then marked that range stale and its next touch
copied from the freed buffer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regions the game has not written for a while are write-protected so that the per-submission write-watch queries can skip them, and the first write thaws them. The game's loaders keep writing the buffers they stream an area into, so those regions were frozen again as soon as they had been idle a moment and thawed again moments later: running from the Kakariko Village gate across Hyrule Field froze 31,004 regions and thawed 28,918 by a write in nine minutes. While the loaders wrote and the driver locked new memory, each thaw's VirtualQuery took 15 to 20 ms: a loader thread stalled 78 to 111 ms in thaws in every one of the hitches of 125 to 140 ms of that walk, with the game's main thread waiting for it.
A write that thaws a region within a minute of its freeze now holds it thawed for 4 seconds, doubled at each such thaw up to 10 minutes; a region that stayed frozen longer starts again from nothing. On the same walk: 8,284 freezes and 6,355 thaws, no frame over 100 ms while Link ran where 29 and 52 a minute had been, the slowest 1 % of frames at 19 and 24 fps instead of 9 and 7, and at the walk's end, Link standing at the edge of Lake Hylia, 60 fps instead of 38 with the slowest 1 % at 43 instead of 20. The perf trace's write-watch line counts the holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game held 23.9 to 24.4 GB of commit in three sessions on an RTX 5090 at 3840x2160, flat over the session, on a
machine whose commit stood at 66 of 73 GB with it. A machine that cannot give it that much pages, drops textures or
loses the device, and its log said nothing about memory until then.
mem_start_note reads the system's commit when a game starts and writes one warning line when what is free, plus what
the process already holds, is below mem_need_mb (24576 by default, 0 for no warning). The menu has no place for a
start-up notice (its only toasts are the co-op page's), so the line is in the log alone.
tests/mem_need_check.py checks the rule and, through --probe-memneed, the answer and the warning line with the
machine's own commit.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A second reading of the commits taken in from pull requests found these, each traced in the code:
- heap_lazy: the camera speed's search read the whole heap, which with the setting on committed every page and undid what it saves; a scan now steps over the pages the lazy heap never committed (mem_untouched_run). tests/frame_step_check.py and tests/save_share_check.py no longer linked (frame_rate.c and fs_host.c call the new memory functions; the second also missed mem_probing before); both pass again. The settings text says that with the setting on a PC out of commit ends the game in play, not at start, and the census comment is back over its own function.
- Pipeline libraries on AMD's older Windows driver: the condition is the driver for RDNA 2 and older (RX 400 to RX 6000), not RX 5000 and RX 6000 only, and the texts named a price the code does not have. With libraries off a new pipeline is built by a worker while its draw is left out, and the start waits for every recorded pipeline instead of 20 s at most. The texts say so, and that the finding is one player's on one card. The menu strings nothing shows are as they were.
- Page file hint: it comes only when Windows sizes every page file, and it names the initial size and the automatic management that must be unticked first.
- Frame generation note: it keeps "needs an RTX 40 or newer" for the players it is right for and points the others to the log; the bridge's line says it is Streamline's general support check.
- Comments and settings texts that still described the old behaviour: the process commit bound, the wait with shadow storage, the scratch's least size, the action prompt under auto.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
(cherry picked from commit 19f03aa01b26d6e17fb5fc70ad987aa8c68e8225)
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>
Loading a save in Hyrule Field at night lost the device about 2 s after the load in 7 of about 22 loads on an RTX 5090 (a GPU read fault at the start of a large buffer, in or right after one of the game's compute dispatches). With the lazy write-back off it loaded 10 times of 10, and with only the piece release off 15 times of 15. How the piece release leads to the fault is not known; until it is, a CPU touch releases its whole range again, as before the town performance pass. wb_lazy_chunk = 1 turns it back on.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Windows charges every committed page to the commit limit whether it is used or not, and the game sizes its whole heap up front: in Castle Town after Hyrule Field the heap held 6.8 GiB of commit of which 3.4 GiB had ever been used. With heap_lazy = 1 a change of the heap's size only moves its end, an access to a reserved heap page commits the reserved runs of the 64 KiB around it in the access-fault hook and runs again, and the paths that do not fault commit their range first: mem_thaw (file reads, the host-pointer import), mem_kernel_access (file writes, the frame-rate fix's read) and mem_host_readable (the host's pointer checks). A page another path committed is left as it is, so a frozen, probed or armed page keeps its protection. With trace = perf a line per window gives the faults, the bytes committed and the time they took.
The contributor's runs on a 16 GB PC: 4.67 instead of 6.77 GiB of heap committed and 29.3 instead of 32.9 GiB of process commit in Castle Town, no "system commit low", 12,858 faults in 440 ms over four minutes, the longest 41 ms. With the setting off nothing is hooked and the heap is committed as before.
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>
A slow frame's line and the 60-frame window's line now say how long each kind took on any thread, how often, how many bytes and the longest single one, so a hitch outside the render thread's own waits has a named cause. While NX2NAVI_TRACE=perf is off each site pays one branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the lazy write-back on, a village and Hyrule Field still merged 20 to 29 MiB a frame back into guest memory at
the end of every submission, inside the GPU work the game's fence waits for. The new log line (mem: lazy write-back
at frame N: written back and not armed since the last line) names the reason by bytes: none of it lay in frozen
regions or on pages that could not be armed; it lay in regions held after a touch. The game's CPU reads a few words
at the start of buffers its shaders fill (12 bytes into ranges of 6.3 and 15.2 MiB among others), about once a
second. Each read gave back the whole armed range and held every 2 MiB region it reached for 1 s, doubling to 32 s,
and nothing was armed in a held region.
A touch now gives back the 64 KiB around its address, fetched alone, and holds only that piece; the rest of the
range stays armed (wb_lazy_chunk, default 1; 0 is the old rule). More than four touches in one region within a
second are taken as a reader going through the range: that range goes whole and its regions are held as before,
and the region's touches go that way at once while it keeps being touched within a minute. Each of the first eight
touches of a log window is logged under the perf trace with its thread, address, range and what was given back.
Measured on one machine (NVIDIA, uncapped, frame generation off, scripted walks, medians over the windows of 46 s
of world clock):
- village: 121.8, 124.1 and 124.4 fps before; 138.0 and 135.4 with this change; GPU busy 4.8 to 4.15 ms a frame
- Hyrule Field: 101.1 and 113.1 fps before; 131.5 and 131.3 after; GPU busy 6.5 to 4.7 ms
- merged a frame: 20 to 29 MiB before, 1.7 to 2.0 MiB after
- faults on armed pages in 1,000 frames: 14 and 16 before, 17 to 18 after in the village; 19 and 23 before, 17 to
21 after in Hyrule Field; time spent fetching the same (4 to 5 ms in 1,000 frames)
A run with synthetic reads of four armed ranges per log window (wb_lazy_touch = 4, 769 faults) ended without an
error. The picture of every presented frame of a village walk and a field walk (thumbnails, 10,131 and 9,531
frames) had no black frame. The village walk has single frames whose brightness jumps: 14 with the change and 28
without it in about 10,000 frames each; the two looked at, one of each, are jumps of the camera.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The default was off only on AMD's driver branch for RDNA 1 and 2, on the grounds that builds with the default on had been measured on an RX 7900 XT. Those measurements were standing in Hyrule Field and walking through a village; the report this is about is of a game that ended in fights, on an AMD card the player did not name, and fights are where the lazy path is busiest. Nobody has played one on an AMD card under our eyes on either branch, so the exception is now AMD's own driver as a whole (wb_lazy_choice.h, by VkDriverId), until a card of each branch has played fights with wb_lazy = 1 and a log to show. The cost is the open-area frame rate of 0.7.0 on every AMD card under Windows; the RX 7900 XT figures in the draft notes come from builds that had it on by default, which the notes now say. wb_lazy = 1 still turns it on.
--probe-vkdevice prints the line a fatal error or a crash adds to the log, so its wording can be read without a failing run. On an RTX 5090: "fatal: gpu NVIDIA GeForce RTX 5090, driver NVIDIA "621.01" version 621.1.0.0; memory mode mirror, lazy write-back on (the default on Windows (wb_lazy = 0 turns it off)); texture detail not chosen yet (no texture made)", and with the reporting player's nx2navi.ini beside the exe "lazy write-back off (wb_lazy = 0 is set)", after a start whose log names async_compute, vram_safe_mode and exception_handler as no settings of this version.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A player on an AMD card reported crashes in fights and that a line added to nx2navi.ini cured them. That line, async_compute = 1, is no setting of the game, and neither are two more in the same file; the one line of the file that changes how memory reaches the GPU is wb_lazy = 0. A second report, an RX 6600 on 0.7.1, lost its device 3.5 minutes into play with the lazy write-back on. Neither proves it the cause: the player changed several lines at once and sent no log, the RX 6600's last pass mark is inside a draw pass and not a fetch, cards with 8 GB lost their device the same way on NVIDIA before the lazy write-back existed, and reading the fault handler, the fetch, the arming and the buffers' lifetimes found no failure path. What depends on the driver and cannot be checked without the card: pages of guest memory are inaccessible while a host-pointer import of them is bound and written by the GPU, and Vulkan calls run inside the access-fault handler.
The default is now decided once the driver is known (wb_lazy_choice.h, vk_memory.c vk_mem_lazy_decide): on under Windows as before, except on AMD's driver branch for RDNA 1 and 2, which none of us has run it on; AMD's driver for RDNA 3 and newer keeps it on, since builds with that default were measured on an RX 7900 XT. What the player sets wins everywhere, and one line at start says the choice and its reason. On those cards open areas run at the 0.7.0 frame rate again (on an RTX 5090 that was 65 fps against 90 in Hyrule Field; not measured on AMD). tests/wb_lazy_check.c checks the choice by platform, driver and setting without a GPU.
For reports of this kind the log now holds what was missing. At start: every setting nx2navi.ini sets, and in one line the keys that are no settings of this version and do nothing; the settings menu shows those in its bottom row. At a fatal error or a crash: one line with the card, its driver, the memory mode, the lazy write-back with its reason, armed ranges, fetches so far and a fetch in progress, and the texture detail (dispatch.c rt_fatal_note_add, vk_device.c fatal_note), since a report is often the end of a log alone.
--probe-cfg loads the player's file: the three lines are named, and the menu's save keeps them and every other line it does not change byte for byte. Nothing here ran on an AMD card, and no fatal error was provoked to read the new line.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Each submission's first sync of the bindless texture pool walked every registered slot, 33,000 in Kokiri Forest, to find the few textures the CPU wrote and the few slots that changed. Three parts of that walk change, each with its own switch (pool_fast, pool_presnap, pool_group; on by default, 0 and ab accepted):
- The freshness check goes through a list of the entries the slots resolved to, with a check by 2 MiB region first; slots that resolved to nothing are looked up again when the game's memory map changes and every eighth submission. The use stamps the idle eviction reads are left from a count of the slots that hold each entry.
- The check takes the write-watch snapshots of its regions first, so unchanged entries pass the region test.
- The GPU-written slots of one address bucket with one descriptor form a group, which the dirty walk leaves alone while its first member is unchanged.
Measured on an RTX 5090 at 2560x1440, standing in Kokiri Forest, in-run A/B over five runs with all three switched (three of them with the barrier change switched too): 93.6 to 100.7, 96.4 to 103.1, 95.8 to 102.6, 99.3 to 107.1 and 91.9 to 99.9 frames a second, the replay thread 0.7 to 0.8 ms a frame shorter; a control's blocks differ by 0.4 %. On a walk with NX2NAVI_POOL_GROUP_CHECK=1 no group that was left alone had missed a write, and screenshots across the A and B blocks show the same picture.
From pull request #390. Not tried: the texture eviction under memory pressure (a 9 GiB budget was never reached on the walk), AMD and Intel drivers, Linux. One of the nine runs with it on ended by itself with exit code 0 and nothing in its log two seconds after the window lost the focus; three repeats of that run did not.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The user asked for this once the flicker fix was measured. The single-frame flicker that kept it off in 0.7.0 came from frames drawn before the game's signal that their data was written, which the previous commit fixes; the lazy write-back only made the frames come faster, so the signal fell into the replay more often. wb_lazy = 0 in nx2navi.ini still turns it off, and an ini that already holds the key keeps its value. Elsewhere than Windows the default stays 0, as its fetch inside the access-fault handler has never run there.
Measured on the build of the previous commit, 1,800 presented frames a run, standing in Hyrule Field by day with DLSS Performance, frame generation 4x and HDR. With the lazy write-back on and eight busy threads beside the game: 38 and 0 single-frame spikes with the fix switched off (408 and 44 frames whose signal came during the replay), 0 and 0 with it on (667 and 724 such frames). With it off and the same load: 0 spikes in four runs, two with the fix and two without, 86 to 140 such frames each. Without load, one run each: 88.5 frames a second with the fix against 93.3 without it with the lazy write-back on (GPU 7.2 against 6.8 ms a frame, 6.53 against 6.48 submissions a frame), 71.0 against 65.6 with it off, so the fix's cost is inside the run-to-run spread; every run ended cleanly without a fatal error. This build was started once with no setting anywhere: the lazy write-back was on, and the Screen tab shows no row for it.
Not measured: play by hand with the lazy write-back on and the fix (the user's sessions so far ran with it off), other areas than Hyrule Field, and more than one run for the frame rates.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With it on, single frames come out with the whole scene lit differently (brighter, shadows off). The flight recorder's
thumbnails of 1,800 presents standing in Hyrule Field show none with it off in every run, and with it on 43 to 181
while four other threads keep the CPU busy (10 to 97 in earlier runs, 0 on an idle machine); the cause is not found.
Two candidate fixes (arming only against a sync whose copies were handed over, and snapshots waiting for GPU writes in
flight) did not change the count, and fetching at every release made it far worse.
So wb_lazy is 0 unless set, on every platform (mem_init loses its special case), and the Screen tab has the row
Experimental FPS boost for it on Windows, with its text in the four menu languages; like every setting read at the
start it notes "after restart". The 0.7.0 notes describe it as experimental and off, say about 1.6 GiB for the video
memory a full shader cache no longer holds (2,484 to 846 MiB at the title screen in the measured run), and list the
two fixes before this one.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A fetch copies the mirror over the whole range, not the merge of the bytes the GPU changed that a write-back does. The
completion thread arms a range after its write-back reached guest memory; a CPU write into those pages in between, not
yet carried up by a sync of the mirror, was then overwritten by the mirror's older bytes at the first fetch. In play
that was single frames with another frame's constants: the night lighting missing from the whole scene in Hyrule
Field every 0.1 to 0.4 s, and a black scene in Kakariko, gone with wb_lazy = 0. Now the pages are made inaccessible
first and the back end then checks the CPU-write page generations against the mirror's last sync and the write-watch
bits nobody has read yet; when either shows a write the mirror lacks, the pages are given back and the range arms at a
later write-back. The lazy line counts the arms refused.
Co-Authored-By: Claude Opus 5.5 <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>
Standing in Hyrule Field the GPU spent 13 ms a frame, of which 2.4 ms were the game's passes and 7 to 9 ms the copy
of 66 MiB of shader-written buffers back into guest memory at the end of every submission, in case the CPU reads
them. The write-back probe found no CPU access to them in 40 s. Now a range goes back once and its pages are made
inaccessible; while they stay so, later writes stay in the mirror. The first CPU access faults, the bytes are fetched
with a GPU copy of their own after everything submitted so far, the pages are given back, and the range is written
back every submission again for a while (1 s, doubling to 32 s while it keeps being touched). Thaws before kernel
reads and writes and before mapping changes release armed ranges the same way, and so does a mirror that goes away.
Dev build, same spot, 80 s each: 51.5 to 71.0 fps median, 1% low 43.5 to 56.3, GPU time 13.1 to 7.5 ms a frame,
fence waits 9.8 to 4.4 ms. In that run the game faulted on armed pages 226 times and 124 fetches took 51 ms in all;
a walk into a fight made 283 faults and 186 fetches, and a test that touches eight armed ranges every 60 frames
(wb_lazy_touch) 480 faults and 364 fetches, none with a fault of its own. wb_lazy = 0 is the old behaviour.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Per-pass GPU timestamps put the write-back of GPU-written storage buffers at 3.6 to 5.3 ms of GPU time a frame in play, against 1.8 to 2.5 ms for all of the game's own passes and dispatches: about 67 MiB a frame goes from the mirrors back into guest memory, from the same eleven pools all session, in case the CPU or a host path reads it. Nothing said whether anything does.
With wb_probe = 1 the 2 MiB regions that lie wholly inside a range just written back are made inaccessible. The first read or write of such a region faults; the handler gives the region back the protection it had (read-only when frozen, else read-write), notes the address, the instruction and the thread, and the access runs again. The bytes are in guest memory all along, so nothing changes for the game. A log block every perf window gives the regions armed, the probes a read or a write ended, and the first accesses by instruction. Every thaw ends the probe of its region first, which covers file reads into guest memory and mapping changes; a file write from guest memory thaws its buffer while the probe is on; a probed region is not frozen anew.
The platform layer gets a hook for every read or write access fault beside the write-fault hook; it is installed only with the setting. --probe-gfx-abi checks that nothing is armed without it and, run with NX2NAVI_WB_PROBE=1, that reads and writes end a probe and run again, that a frozen region thaws after it and that mem_thaw ends and holds.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The commit census of NX2NAVI_TRACE=perf with NX2NAVI_MEM_CENSUS=1 reported the process's committed memory by kind,
which lumps every guest region together. memory.c mem_guest_census now walks each reserved region (aslr, stack, heap,
alias, kernel, host functions) beside it and reports the bytes committed in each, so a process that has grown to thirty
gigabytes (issues #203, #215, #240) says which region to look at. Like the census, it runs only when mem_census asks
for it, since the walk holds the address-space lock the game's threads take at every page fault.
On the Kokiri route the answer was the aslr region at 28 to 31 GB, the heap at 7.5 GB and the alias window at 2.7 GB
while the physical-memory pool itself stayed near its limit, which is what the device-loss crash follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The draw path called getenv("NX2NAVI_SKIP_PROG") for each of ~2,000 draws a frame, and getenv walks the whole environment block: in a replay-thread profile it was a sixth of the samples. That switch, the polygon offset log and the ASTC alpha probe now read the environment once. The replay's cost per draw went from 3.7-5.0 us to 1.2-2.8 us in play, and the mean frame rate by minute from 60-131 fps to 103-243 fps (different places in each run, so the figures are approximate).
cfg_get and cfg_value keep each thread's answers per name until the settings version changes (cfg_set, cfg_reset, cfg_load, and cfg_setenv, which the code that sets NX2NAVI_ variables in the process's own environment now uses); NX2NAVI_CFG_MEMO=0 asks every time.
The texture pool creates at most pool_create_budget (NX2NAVI_POOL_CREATE_BUDGET, default 16, was a fixed 64) new texture entries per refresh. The refresh holds the texture lock throughout, and streaming new textures in made 20-40 ms frames; at 16 the lock was held 5 ms per slow frame instead of 24 in a dense area, and the frames over 25 ms in a heavy minute fell from 111-188 to 89, with no pop-in seen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shaders: a switch whose index is clamped against a register that a MOV of an immediate set before it now finds its
jump table (AmpereLowering.cs BrxTargets), so all 36,031 of the game's shader variants translate and no draw is skipped
for a failed one. The compare-and-swap loop behind storage-buffer atomics (patch 0033) takes the value a failed swap
returns as its next expected value instead of loading the word again, which on some drivers kept returning a stale
copy.
Memory: an unused physical pool outlives the submission being recorded, as virtual pages may still reach it through
device addresses; an image whose creation failed is not tried again for 2 s.
The installer and Linux texts name this game.
The README has the project icon, the current status, features, requirements and known issues.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crashes: the render-target heap (HeapDef DynamicTextureAllocatorMem and its parent) grows with the widescreen
picture, so the pause menu no longer reads a null block at 5120x2160; texture uploads keep the game's memory mapped
while they copy it, so UnmapPhysicalMemory no longer pulls it away mid-copy; DLSS Frame Generation no longer crashes
in Streamline's set-up (physical devices and the window surface go through its proxies, its swapchain images are
counted first, resources are tagged per frame); a stale core-wait target is cleared before its thread is freed.
Freezes and races: a resize during a swapchain rebuild is kept, a failed frame generation swapchain is retried
instead of replaced by a plain one, three acquire timeouts in a row rebuild the swapchain, device waits take the
queue lock, a translator child that stops answering times out, an in-flight compute pipeline builds at normal
priority, a save Commit started before the window closes finishes and none starts after, a settings change just
before closing is saved, and a movie sample taken before a flush is dropped.
HDR: Enhanced HDR extends the game's own tonemap so highlights go above the picture's white (the hue slider,
which no longer applies, is gone from the menu); frame generation gets a HUD-less picture, the HDR flag and the HDR10
metadata under HDR too.
Memory: ASTC textures are encoded to BC3 unless the PC has 20 GiB of VRAM and 48 GiB of RAM, as Windows charges video
memory to the commit charge (a long session held 17 GiB of RGBA8 textures and 45.7 GiB of commit on a 32 GiB PC).
Shaders: the translator lowers spill-mode programs with more than eight uniform register groups in a wider spill
layout, and local loads and stores with a uniform-register address (36029 of the game's 36031 variants translate,
20 failed before).
The launcher release ships the shader tools and AMD's FidelityFX library (make_launcher.py --ffx); windres there gets
the toolchain on PATH.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each file that contains copied code names its copyright holder and license and points at THIRD_PARTY_NOTICES.md; zlib code keeps its full notice and the mark that it was altered. Comments and probe text only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments say what the code does and why, docs/maxwell.md keeps the Maxwell facts, and fetch_refs.sh clones only the checkout the build reads. Code is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The NVN2 front end and the SM86 to Maxwell shader lowering (tools/xlate/AmpereLowering.cs, mesh and task stages,
bindless texture pools, BRX jump tables, the NVN2 compute c0 layout) with four translator patches (0017-0020) bring the
opening of the game to 58-60 fps on the Vulkan renderer.
This round fixes the haze: SM86 IPA is lowered as a plain Maxwell IPA.PASS (texture coordinates were corrected for
perspective twice); LDSM, TXD, both F2IP byte forms, HMMA, MOVM and HFMA2.RELU are lowered and spill mode has eight
uniform register groups; conditional rendering keeps the newest counter report per address and treats a report as
pending until its value is in guest memory, so occlusion tests and the depth clears around the depth copy behave; and
the upscaler stand-in decodes the post pass's sRGB picture before writing the linear target. Tensor-core programs keep
the stand-in unless NX2NAVI_NATIVE_TENSOR=1, because their lowering does not match the hardware yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>