The review of the ExitProcess note found that platform/win32.c plat_exit_note_modules, which runs on the window thread
at every focus loss, was quadratic in the number of modules: GetModuleInformation and GetModuleBaseNameA each walk the
loader's module list through ReadProcessMemory. Its probe measured 27.5 ms a note with 292 modules.
Each module's size now comes from SizeOfImage in its own image header and its name from GetModuleFileNameA, the part
after the last backslash. The header is read through ReadProcessMemory into a 1 KiB buffer rather than in place: a
module unloaded since EnumProcessModules then fails the read and is left out, where a plain read would fault. The
table's contents are the same.
Measured with a probe beside this change (283 modules loaded, scratchpad rv_fxexit_fix\modtime_rpm): 25.3 ms a note
before, 0.27 ms after, and both ways note the same 283 bases, sizes and names. tests/exit_tls_check.py, whose note
names the program's own module from that table, and the compile check of win32.c pass, with no new -Wall warnings.
The review's optional second note after the device is made is left out: vk_device.c create_device is another
branch's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two runs of 2026-11-13 (the 0.10.0 fresh install's nx2navi.1.log, 7,974,927 bytes, and the 0.9.2 test's
nx2navi.3.log) ended seconds after "window lost the focus" with exit code 0, no WER event and no line of their own:
an ExitProcess or exit() called by a module in the process and a kill from outside could not be told apart, and
both lose the up to 250 ms of lines the log thread was gathering. A direct ExitProcess skips perf.c's atexit sync.
(a) ExitProcess stops every other thread and then calls each module's detach, the executable's TLS callbacks
included; TerminateProcess calls none. win32.c exit_note_tls, in the .CRT$XLB list beside _tls_used, writes on
DLL_PROCESS_DETACH what the log queue holds (logq.c logq_peek: the bytes as they stand, no lock) and one line,
"exit: the process is ending through ExitProcess, not one of the game's own ends", with the return addresses that
led there. It runs under the loader lock with the other threads gone, so it uses no C runtime (hex written by hand)
and does not ask the loader: the addresses are named from a module list noted at start and at each focus loss
(plat_exit_note_modules: EnumProcessModules, two tables and a writers' lock the callback never takes), and
RtlCaptureStackBackTrace and WriteFile take no lock of the process. plat_exit, plat_terminate and plat_reexec set
the orderly flag (plat_exit_orderly), and main does once main_run has returned, so a tool mode's exit() leaves no
line; a library's exit() during a game run does, naming ucrtbase and its caller. main.c log_open sets it up for a
game run that writes a log file. Linux has empty stand-ins.
(b) gfx_window_focus_changed calls the new main.c rt_log_focus_lost at a focus loss: logq_nudge makes the log thread
write what it gathers at once (a flag log_main's gathering loop checks) and the module list is noted again.
Not reproduced before the change (the two ends are unexplained). The new tests/exit_tls_check.py links win32.c and
logq.c with a queued line still being gathered: ExitProcess from the program and exit() write the line and the note
naming exit_tls_check.exe; TerminateProcess, plat_exit and a return after plat_exit_orderly write no note.
tests/logq_check.c gains a nudge case, which fails with the flag left unset (252 ms) and passes with it.
thread_end_check, fatal_exit_check, crash_stack_check, exit_order_check, save_share_check and the compile checks of
every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main.c foreign_hook matches "hook", so a crash in RTSSHooks64.dll got the line that the library is not part of the
game and that a recording, overlay or capture program put it there, also when the game's own thread that sets the
frame limit (rtss_limit.c's "rtss" worker) had loaded and called it. The one RTSS crash on record (issue #231)
faulted in ntdll on that thread, where no note applied. And the crash handler then called rtss_limit_restore from
that thread, whose own lock it holds (SRW locks are not recursive), so the restore's trylocks spent a second.
The platform layer keeps the name a thread was made with in the thread itself (plat_thread_name, copied at its
start: a detached thread's struct may go before it ends). main.c crash_module_note picks the note: on the "rtss"
thread, whatever the module, the line says the fault is in the game's calls of RTSS's library or around them and
that rtss = 0 keeps the game from calling it; on other threads RTSSHooks64.dll stays foreign, as it is RTSS's
overlay there. The restore is skipped on the rtss thread. rtss_limit.c is unchanged: the thread is known by the name
it is spawned with. Crash text only, besides the skipped restore.
Not reproduced before the change. tests/foreign_hook_check.py checks the note's choice (RTSSHooks64.dll and ntdll on
the RTSS thread: the RTSS note; RTSSHooks64.dll elsewhere: foreign; a driver: none), and tests/crash_stack_check.py
that the crash report reads the faulting thread's name. They, thread_end_check, fatal_exit_check and the compile
checks of every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
win32.c con_ctrl returned FALSE for a second Ctrl+C, Ctrl+Break or close while the first one's end ran, so the next
handler, the default one, ran ExitProcess at once: a save Commit in flight was cut (the next start rolls it back)
and every library's detach ran. Only a game started from a console has the handler; the launcher starts it with no
console.
The second event now waits for a Commit under way through fs_save_crash_wait (2 s at most, no lock; weak in win32.c
for builds without fs_host.c, as dispatch.c has it) and ends the process with plat_exit and STATUS_CONTROL_C_EXIT, the
code the default handler gives. The force-quit stays; it takes up to 2 s longer when a Commit runs.
Not reproduced before the change (a play-test item from a console: Ctrl+C twice). By review; fatal_exit_check,
logq_check and save_share_check, which link win32.c beside a weak or a strong fs_save_crash_wait, and the compile
checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stack overflow leaves the faulting thread only a guard page's worth of stack for the handler, and the crash
report needs more: main.c crash_handler's rt_log buffers are 2 KiB each, it walks the frames, and while the boot
builds pipelines its first call writes a file (plat_file_write's frame is about 4 KiB). Nothing in the runtime set
a stack guarantee, so the report faulted again; the verifier's probe ended with 0xC0000005 and no line once the
report's first call was the file write. The launcher starts the game with stderr on DEVNULL, so raw_note's stderr
line was all there was, and nobody saw it.
The new platform plat_stack_guarantee calls SetThreadStackGuarantee with PLAT_STACK_GUARANTEE (32 KiB) and runs
at the start of every thread win32.c thread_main starts, in fiber_start (a guest fiber's stack is its own and the
guarantee belongs to the running fiber), and on the main thread in main. Each thread and fiber commits that much
more of its stack. Linux's crash handling already runs on its signal stack (linux.c alt_stack), so it is empty
there.
Reproduced before the change by the new tests/crash_stack_check.py: a plat_thread_spawn thread and a plat_fiber_new
fiber recurse until their stacks run out, and the handler writes a file and three 2 KiB log lines. Without the
guarantee both ended with 0xC0000005 and no line; with it both write every line and end with 71.
thread_end_check, fatal_exit_check and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dispatch.c fatal_end and vk_device.c lost_wait_exit ended with _Exit, which in the C runtime is ExitProcess: it
stops the other threads wherever they are and then runs every library's DLL_PROCESS_DETACH on the ending thread
(the audit's probes show the exe's TLS callback getting it after _Exit and not after TerminateProcess). That is
the detach plat_exit already avoids, after NGX's faulted once at the end of a clean run. After a FATAL such a
fault would enter the crash handler and replace code 70 or 72 with a crash report and code 71; after a device
loss a driver detach that hangs would hold the process for good, since ExitProcess had stopped the watchdog too.
The new platform plat_terminate ends the process with the code and nothing else (no exit hook, no stream flush:
the failing paths have written their lines and flushed). fatal_end calls it after its existing log sync and
flushes. lost_wait_exit tells its watchdog (spawned with a non-NULL argument) from the threads that stop there:
after LOST_EXIT_MS the watchdog alone writes "vk: the end of the run did not finish in 10 s; ending it" through
rt_log_sync's bounded trylocks, and the others end 2 s later only should the watchdog not have started. Exit
codes stay 70 and 72.
Not reproduced before the change (no detach fault on a fatal path has been seen). tests/fatal_exit_check.py
(new: dispatch.c and the platform layer with a TLS callback; rt_fatal ends with 70, its FATAL line and no detach
call, which it showed with _Exit; and statically, no _Exit in runtime outside the platform layer, and both ends
call plat_terminate) and the compile checks pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plat_child_read returned -1 both for a broken pipe and for a child that sent nothing for PLAT_CHILD_READ_MS (120 s),
and svc_request retried any failure once on a fresh child. The translator is deterministic, so a program it hangs on
hangs the second child too: a compute shader, which the replay thread looks up synchronously (vk_exec_draw.c
shader_for), held the game for about 4 minutes instead of 2. plat_child_read now returns -2 on the timeout (win32.c and
linux.c), child_exchange_raw returns 1, 0 for a broken pipe or a child that could not start, and -1 for the timeout,
and svc_request stops the child and gives -1 without asking again; a dead child is still restarted once.
translate_one fails such a request with its own reason, keeps its job and code for diagnosis and deletes whatever the
stopped child wrote. The carry pass's child runs at idle priority, so a busy machine can starve it that long:
carry_exchange does not resend after a timeout either, and carry_translate counts it as a failed input, not as an
unavailable translator, so one slow program does not count towards the three that stop the pass. platform.h lets a
build set PLAT_CHILD_READ_MS.
Not reproduced before the change (no translator hang appears in the available logs). tests/child_timeout_check.py
(new) builds the real child and request functions with a 300 ms timeout: a silent child gives -2 and costs one
timeout with one child started, an exiting child is restarted once, an answering child gives its line. Every runtime
file compiles, with no new warnings in gfx_shader.c and win32.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session of 2026-11-13 grew from 683 to 1095 OS handles in 11 minutes, rising at every area load and never falling,
while the guest's own handles stayed at 116-122 and the threads flat; the VMA block count fell back while the handle
count did not. The log could not show it: the 600-frame "mem: sizes" line had only the guest's handles, and nothing
told which kind of handle grows.
kernel.c kern_sizes now adds "process handles" (plat_handle_count, one system call every 600 frames under trace=perf)
beside the guest's "handles". platform.h gets plat_handle_census: on Windows the system's extended handle table
filtered by our process id, with the type names from NtQueryObject's ObjectTypesInformation; on Linux the
/proc/self/fd links grouped by kind. It never opens, duplicates or names a handle (a name query can block on a pipe).
With NX2NAVI_HANDLE_CENSUS=1 (default 0, read at start) and trace=perf, perf.c starts one long-lived thread that the
600-frame report wakes; when the count grew by 16 or more since its last census it logs "mem: handles frame N: T in
all (+D since the last census): Event a (+x), ...". The census reads a few MiB of the system's table in 2-5 ms, off
the drawing threads; one thread for the run keeps a new thread's own handle out of what it counts. New cfg rows:
HANDLE_CENSUS in known[], sections[] (diagnostics) and metas[] ("0", "0|1", restart), and an nx2navi.ini.example
entry.
New tests/handle_census_check.py builds the platform layer alone: 20 events and 5 sections show as Event +20 and
Section +5, the total stays within 2 of GetProcessHandleCount, the figures return once they are closed, and kinds
past the room given go into the last entry; docs/building.md describes it. Not reproduced before the change (the
growth needs a game run). handle_census_check, sync_wake_check and the cfg row check pass; every runtime file compiles
with the dev flags, and perf.c, kernel.c, win32.c and cfg.c add no -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The system line took the driver's device-local figure out of the process's commit, but the driver's write-combined views of that memory came to 0.2-1.1 GiB more, and that part went to "other": in the 2026-11-13 session other system memory grew 1,283 MiB in play while the process's own commit grew 307. With a walk of the address space (mem_census) less than a minute old, the gap at that walk is now a term of its own. The NVN recording copies, malloc'd, count among the C runtime heap's known parts with those unused for 5 minutes, and the line ends with the process's handle count, which rose at every area load in that session and never fell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
floor_watch.c takes up to twelve of the floor scan's survivors (the platform's rigid body controller unit first, then one per object at the pivot's height), watches them four at a time through the debug registers on every thread (platform.h plat_watch_set, Windows only), counts each access's call chain in a lock-free table and logs the chains with the executable's symbol names after each pass. prof_syms gains a callback form for that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every report adds a line for video memory outside VMA (the driver's usage and budget per heap against VMA's blocks, and counts of pipelines by kind and by last use, stage libraries, compute pipelines, layouts and descriptor pools, with the swapchain, DLSS and FSR sizes), a line that splits the process's commit by owner (the game's heap and maps, device-local memory, VMA's host blocks, the C runtime heap with the shader caches, pipeline tables, texture cache, log queue and flight recorder in it, the driver's pipeline cache data and the rest), and a line naming what grew by more than 64 MiB since the report before and since play began. Every ten minutes of play and at exit a ranking line orders the categories by growth and marks those that only climbed; tests/mem_growth_check.py checks that bookkeeping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A selection in the console window of a game started directly held back the write to stderr, and with it the log
file, the exit hook and the crash report. The log file now comes first, stderr goes past the C runtime's stream lock,
and the lines of an exit, a fatal error or a crash skip stderr while another thread is stuck in it. A game run turns
the console's QuickEdit mode off, and a closed console, Ctrl+C or Ctrl+Break ends the game the way a closed window
does, with the settings and saves written and the exit logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hidden beta shared story progress through a relay a player had to run and never showed the other players. A new co-op replaces it. Gone are runtime/coop*.c and their headers, the menu's Co-op tab and its texts in four languages, tools/coop_relay.py, the co-op tests, docs/coop.md, the [coop] section of nx2navi.ini.example, the --probe-coop mode, the coop trace item, and Monocypher, which only this layer used.
The old settings stay known as retired keys, so an nx2navi.ini that still sets them logs no unknown key, and they stay on the list of private settings, so a bug report keeps a room's secret, a relay's address and a name out. The platform layer's UDP sockets and fs_save_snapshot stay for the new co-op; cfg_ini_now and fs_profile_path, which nothing else called, are removed.
Checked: the game builds and starts, the menu shows its four tabs, --probe-cfg and --probe-fs pass, and the launcher's private-settings test passes. tests/save_share_check.py builds without its co-op stub; it cannot run from this worktree because the folder's path is longer than Windows allows, on main too.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The section described the build of the other game versions with an example for 1.4.3. It now says how make_game.py
takes a kit's profile and when it leaves it out, which LLVM releases write and read the file, which packages the
instrumented build and the merge need, and the steps of a training session for 1.1.1: the build command, where the
game is started, what to play, where the counts land, the merge, and the comparison of both builds with the runtime's
sampling profiler and its frame timings.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every guest thread is a host thread, and the game makes and ends them all through play. A thread that slept through plat_wait_ns kept a waitable timer, one that read or wrote a file an event for each, and one that decoded zstd through the host a decoder context of about 100 KiB; nothing closed or freed them when the thread ended, and a detached thread's own record stayed allocated. They are now given back when the thread's function returns: the handles in win32.c thread_main, the decoder context through plat_thread_end_hook, and the record by whichever of the thread and its detach comes last.
tests/thread_end_check.py runs 416 such threads and compares the process's handle count before and after. tests/sync_wake_check.c gets the two stand-ins kernel.c has needed since the service wait log, so that check builds and passes again. The Linux file only gains the hook call and was not compiled here.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
On Linux every CPU-visible pool had a host-visible shadow copy, because amdgpu treats a host-pointer import as a userptr object that it looks up again at every submission and invalidates whenever the write watch or a freeze re-protects its pages. Now the watched reservations are shared mappings of one memfd, /dev/udmabuf makes dma-bufs of page ranges of it, and the device imports them (VK_KHR_external_memory_fd, VK_EXT_external_memory_dma_buf). A dma-buf pins its pages once and is not tied to the process's page tables, so protection changes leave the GPU's mapping alone, and nothing is copied.
RADV puts every imported memory object into every submission, so pools share their imports: guest addresses are cut into 64 MiB spans (NX2NAVI_DMABUF_MB), and each pool is a buffer bound into its span's memory at its offset. A new span is cut back at the spans already there, so spans overlap only where a pool itself lies across an older span's edge, since amdgpu counts every span's pages in its GTT use. A decommit punches the pages out of the memfd, or zeroes them in place where a dma-buf pins them, and the pages a freed span pinned that nothing committed go back to the system.
It is on by default where /dev/udmabuf opens, udmabuf's size_limit_mb is 1024 or more, the device has both extensions and NVIDIA's kernel module is not loaded. A start-up probe imports one page as the pools will be; any refusal keeps the shadow copies, and the log says why. NX2NAVI_DMABUF=0 keeps the copies. tests/platform_dmabuf_test.c checks the shared mapping without a GPU, and docs/building.md ("Memory") describes the imports and what they need.
RX 6800 XT (RADV, Mesa 26.2.6), one build, interleaved pairs with NX2NAVI_DMABUF=1 and 0: Kokiri Forest 60.7 against 40.3 fps (3 pairs, +51%, standard error 0.4), 1% low 50.9 against 34.0, frames over 25 ms 5 against 768; Hyrule Field 55.7 against 42.4 fps (2 pairs, +31%, standard error 0.4). No pool was left without a span, no span was refused, and --probe-vkmem passes with and without the imports.
The author of the pull request it was ported from closed that request on 2026-11-11 with the words that they give no
permission to use its code. The history (NX2NAVI_VA_REPORT, runtime/vk_vaddr.c, the stack walk it used and its notes
in the docs and the example settings) goes; the lost device's report keeps the breadcrumbs, the pass marks, the video
memory and the driver's fault information.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
cache_load measured pipeline-cache.bin with ftell into a long, which is 32 bits on Windows: a file past 2 GB measured as -1, so the run started with an empty cache and the log said nothing, not even that the file was over pipeline_cache_max_mb. The read now lives in runtime/vk_cache_file.h: the length comes from the 64-bit tell, the read runs in 64 MB pieces, and a refusal returns its reason and the file's size, which cache_load logs. The Windows atomic write no longer cuts a block of 4 GB or more to a 32-bit count.
tests/pipeline_cache_file_check.py reads a sparse file just over 2 GB through the header. With --probe-prewarm and such a file carrying this GPU's header, the log reads "holds 2048 MB, over NX2NAVI_PIPELINE_CACHE_MAX_MB 512" under the default limit and "2048.0 MB read" with the limit off; the exe before this change logged nothing for the same file.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Without the other players drawn in the world, which are unfinished. No relay address is built in, and with co-op off nothing opens a socket. Built on Windows and tested against a relay on this PC only; two games in one room and any real network have not been tried.
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)
Each player keeps their own game and save. One player makes a room, the others join it with its secret, and story flags and, by the room's rules, items, songs, quest items, small keys (a shared pool), rupees and ammunition reach every save. The layer is a host thread with one UDP socket to a relay the players run themselves (tools/coop_relay.py, Python's standard library only); the messages between the players are sealed with a key from the room's secret (XChaCha20-Poly1305, Monocypher 4.0.2 vendored with its licence and a row in THIRD_PARTY_NOTICES.md), every change is signed by the player who made it, a player accepts a room by its members' keys and the key of its rules, the save is copied to coop-backup before anything is applied, and values are set through the game's own routines between frames. The Co-op tab makes, joins, accepts, removes and leaves, and the menu takes typed text for the name, the relay and the secret. This is the contributor's pull request #518 on this line.
Nothing reaches the network while co-op is off: the socket is opened, and the relay's name looked up, only after co-op is on, a relay is set and a room is made or joined. There is no default relay.
Left out of the pull request: the other players drawn in the world (the "puppets") with the copied engine they need. They were unfinished, added two patch sites to the game's per-frame update that could not be put through the differential tests here, and their file helpers clashed with the ones this line already has. So no position is sent: the POS message's codec stays, nothing sends it.
Changed against the pull request:
- The menu's test hook drives the Co-op tab only when it comes from the environment. From nx2navi.ini it could turn co-op on, join a room and accept its offer.
- The log's label for a room is hashed from the secret instead of being its first three characters.
- A game that has not saved yet has no save to copy, and takes nothing from the room until it has saved once; that used to count as a copy made.
- A PING's token no longer carries the PC's uptime.
- On Windows the maker's key file and its folder get an access list with the player's account alone.
- The tests that link the platform layer get the socket library, and the save test a stand-in for the title screen's hook.
Checked on Windows, where the pull request had never been compiled: the runtime builds and links; --probe-coop passes (sockets on 127.0.0.1, the codec against its byte vectors, a fuzz of every parser, two sessions through a hostile mock relay, a relay restart, the sync engine); tests/coop_relay_test.py passes its 61 tests, with the probe through the real relay on 127.0.0.1 at 0 and 30 % loss; tests/coop_page_check.py passes and its deliberate fault fails 48 checks. Not checked: two games in one room (one game runs at a time here), any real network, the tab in the game.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With the page file left to Windows the commit limit stays near the installed memory plus what the file held at the start, and on a 16 GB PC with a 16 GB card it ran low in Hyrule Field at 32 GB, where a fixed page file of 32 GB or more gave 49 GB and no low commit. The first "system commit low" line of a run is now followed, once, by a line that names the cause and the Windows setting, and the build kit's guide gives the same advice for 16 GB PCs. plat_page_file_managed reads the PagingFiles value once; on this PC (page file sized by Windows) it returns 1.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
After a lost device the driver's fault report gives an address and nothing says what was there. With va_report = 1 (NX2NAVI_VA_REPORT, off by default) the game keeps every address range the driver binds to a buffer or image and every range it unbinds (VK_EXT_device_address_binding_report, runtime/vk_vaddr.c). The report of a lost device then names the ranges that covered each faulting page, or the nearest ones when none did, and vaddr-history.txt beside the log holds the whole history, also when the driver gives no fault report. With 2 the call stack behind each range of 1 MiB or more is kept too. A player sees nothing of this unless the setting is on: without it no extension is enabled and no callback runs.
From pull request #349. Its second part, which stops a frame dump from turning on mesh query statistics, is left out: it changes what a dump writes for everyone who uses one. Built for Windows; no device was lost to try it.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
On Linux each Restart from the menu cost the next game one more processor core on machines with 12 or more logical processors. A process started with posix_spawn begins with the mask of the thread that spawned it, and since the recording thread has a core of its own no thread has every processor: the restarted game began without that core and took the next fast one for its own recording thread. plat_restart (runtime/platform/linux.c) now widens the calling thread's mask to all of the process's processors for the time of the spawn and puts it back. A mask set from outside is kept, helper programs started with plat_spawn are unchanged, and Windows is not touched.
From pull request #435, with its test tests/platform_restart_mask_test.c, which needs Linux. Not compiled or run here: this machine has no Linux compiler.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every log line was two writes on the thread that logged it, to stderr and to the log file. Such a write can stall for
tens or hundreds of milliseconds (a busy disk, a virus scanner, a console or a pipe that is slow to take it), and the
render thread logs too: a frame it spent in a write was a hitch with nothing else to show for it.
In a game run the lines now go into a queue that a log thread writes out in order (runtime/logq.c). A fatal error,
the crash report and the end of the process write the queue out on their own thread first and write their own lines
directly, so nothing is lost to an exit. A thread that finds the queue's lock held for long, or the queue full, writes
its line itself. NX2NAVI_LOG_ASYNC=0 writes every line where it is logged, as before.
Every write is timed in both modes: a "log: ... writes of the log took over 2 ms" line counts the slow ones, gives
the longest and says whose time they took.
tests/logq_check.py checks order and completeness with eight threads, a slow destination, a stopped destination with
the queue full, and the synchronous flush.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
On Linux the write watch protects pages, so a write-back from shadow storage into guest memory took a fault
at the first store into every page. plat_mem_prepare_write clears the protection of a range with one
UFFDIO_WRITEPROTECT call; the pages count as written, as they would after the faults. Off by default;
Windows has nothing to clear.
Pull request #327 by theurbanninja1993-sketch (a draft on GitHub). The platform function and its Linux
test are taken as they are. The call moved: the write-back it was written for is now shadow_write_back in
runtime/vk_memory.c (a file other sessions are changing), where it covers the runs of pages a submission
changed, and the setting is registered in cfg.c and described in nx2navi.ini.example, which also says
that the lazy write-back stays off with shadow storage.
The Windows files compile; the Linux code and its test are not compiled or run here.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
On Linux recorder_core reserved the last logical processor of the mask (an efficiency core on a hybrid
CPU) without its sibling, for the first thread to call vk_exec_record, and the first process-wide affinity
call took it back. plat_cpu_reserve there now takes the fast core that clocks highest with both of its
logical processors (none when fewer than four cores are fast), threads the holder starts get the other
processors, plat_process_affinity leaves the core with its holder, and a sweep every 300 presents moves
threads that inherited the core and gives it back once the holder has ended.
Shared code, so Windows changes too: the priority and the core go to the first thread that has spent half
a second in vk_exec_record (vk_exec.c rec_claim) instead of the first to call it, and the log names the
logical processors.
Pull request #358 by mooki000, taken as it is. The Windows files compile; nothing was run, and the Linux
code is not compiled here.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Linux overcommits, so plat_commit_enforced was 0 there and nothing the runtime does when memory runs short
ever ran: no stale-texture drops, no idle or disuse eviction. plat_commit_info on Linux now reports the
limit with the least headroom of the cgroup v2 limits the process lives under (memory.max, memory.high,
against memory.current less its file cache), the machine's RAM (MemAvailable against MemTotal) and, with
vm.overcommit_memory = 2, the commit limit; a read is reused for 250 ms and the first one is logged. The
spare VRAM blocks stand down under low memory only where device memory counts against that limit
(plat_commit_counts_vram: Windows, and any integrated GPU). Windows behaves as before.
Pull request #378 by mooki000, taken as it is. The Windows files compile; the Linux code is not compiled
or run here.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Windows places a game on the V-Cache die only when it recognizes the exe as a game, and it does not know this one, so the threads were spread over both dies by frequency preference. cache_die = 1 (the default) narrows the process to the logical processors that share the largest L3; it never widens the set the process was given, and does nothing where every L3 has the same size. The recorder's reserved core is then chosen inside that set.
Co-Authored-By: Claude Fable 5.2 <[EMAIL]>
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 last-chance exception filter is set at the start, before the GPU driver and the other DLLs load. A DLL that sets
its own filter later takes its place, and a fault in that DLL then ends the game with no FATAL line in the log. Two
0.6.0 logs from an RX 7800 XT end that way on the title screen. Once a second the filter is now put back and the
module that replaced it is logged (NX2NAVI_CRASH_FILTER_GUARD, default 1). When the game ends with a nonzero code,
the launcher adds that code, in hex with its meaning, to the end of logs\nx2navi.log, so a fail-fast exit that no
handler in the process can see still shows in the log a player sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every shader cache key names the translator, so a release with a new translator had no cached translation at all:
the launcher re-translated about 42,000 programs before play, and play waited for any program not prepared.
The cache now lists the translator identities it holds translations of in shader-cache/identities.txt, newest first.
A shader the current translator has no translation of uses a previous one's, looked up with that identity mixed with
this machine's host options and read with the usual checks; it keeps that translation for the rest of the session, so
no pipeline is built twice. After the boot prewarm a background pass translates every stored input that has a previous
translator's entry again under the current one, on its own translator child at idle priority that waits while the
game translates. A program the new translator refuses keeps its old translation and is logged. Once the pass has gone
through every such input, the old entries of what it covered are dropped: loose files are deleted, and a pruned copy
of shader-cache.pack replaces the pack at the next start when the pack is still the one it was copied from.
--prepare-shaders still translates everything under the current translator. NX2NAVI_SHADER_CARRY=0 keeps the earlier
behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each translation flushed its .spv and .res.json to the disk, wrote its .use mask with a flushed, written-through
rename and renamed its outputs written through: about six waits for the drive per shader. An NVMe drive does about
1,100 flushed files a second, a SATA SSD or a hard drive far fewer, so the translators sat mostly idle. Every read
already checks these files (spirv_complete, reflection_valid, the .use magic and size) and a torn one is translated
again, so they are now written whole but not flushed (plat_file_write_cached, plat_file_replace without durable).
The .in files stay flushed: they hold the guest's code and only their header and size are checked.
4,683 shaders of the install's preparation on a Ryzen 7 7700X and a Samsung 990 PRO: translation 36.4 and 37.2 s
before, 18.4 and 18.9 s after; every .spv, .res.json, .use and .in file and shader-prepared.bin are the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plat_frame_capture was a stub on Linux, so the font rasterizer's first setjmp ended the run (setjmp: no host frame). It now walks the stack with libgcc's _Unwind_Backtrace and fills a PlatJmp of its own (glibc's jmp_buf mangles rsp, rbp and rip, so it cannot be filled by hand); plat_jmp_save and plat_jmp_go are small SysV assembly routines like the Win32 ones.
plat_fiber_entry called the fiber function with rsp 8 bytes off the 16-byte alignment the SysV ABI requires, so the first aligned SSE store on a fiber (movaps in ov_poll) faulted as an access violation at address 0. It now aligns rsp before the call.
Co-Authored-By: Claude Opus 5.5 <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>
Performance: draws reuse the bindings that did not change and record only the dynamic states that differ from what
the command buffer holds; small uploads come from a staging arena; finished submissions are held briefly and handed to
the queue in batches; the recorder thread can keep a CPU core of its own; ASTC block-linear textures are deswizzled on
the GPU; dynamic resolution steps the upscaler mode to hold a frame-time target. Each has a setting in nx2navi.ini
(bind_reuse, dyn_delta, stg_arena, submit_batch, recorder_core, recorder_prio, astc_gpu_deswizzle, dyn_res) and the
staging cache now frees what it holds at shutdown.
Capture: NX2NAVI_DUMP=key saves the frame after each press of Insert, with mesh draws listed by task, mesh and fragment
shader, depth test and face culling, and mesh_prims.txt giving each mesh draw's primitive, clipping and fragment counts.
All of it is off unless NX2NAVI_DUMP is set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NX2NAVI_TRACE=audio logs voices starting, stopping and sharing a wave buffer; NX2NAVI_DUMP_MOVIE dumps movie frames; NX2NAVI_LOG_CS_BINDS says why the stand-in for an untranslated compute program skipped a dispatch; and NX2NAVI_TRACE=perf adds heap and commit census lines on their own thread, with VMA maps when write-combined memory grows by 2 GiB. From a tester's fixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an hour at 2160p the 2048 released entries held 5-7 GiB of render targets and decoded textures, charged to Windows' commit as well. NX2NAVI_TEX_RELEASED_MB caps them by size (default 1/16 of RAM, 1-4 GiB), and with less commit free than NX2NAVI_TEX_COMMIT_FREE_MB they are dropped to 256 MiB. Only entries released 120 frames ago or more go, at most 32 a check, oldest first. On Linux the commit check applies only with vm.overcommit_memory = 2 (plat_commit_enforced). From a tester's fixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing kept a second copy of the game off the same profile (a double start, or the restart going ahead after 30 s
while the old process still ran). Its start took the first copy's commit in progress for an interrupted one and
deleted it, removed the first copy's working copy under its open files, and then both wrote into one save-tx/work,
so saves were lost or mixed. The save storage now takes <profile>/instance.lock (plat_lock_file: a file no other
process can open on Windows, flock on Linux) and a second copy stops with "another copy of the game is running".
--probe-fs gives the lock back before deleting its temporary profile and passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Opt the process out of Windows power throttling (execution speed and
timer resolution) at startup; NX2NAVI_POWER_FULL=0 turns it off.
- Fence completion thread wakes waiters before collecting freed memory,
so collection no longer delays them.
- Add a by-region barrier when a draw or clear writes an attachment that
an earlier draw in the same pass sampled; count it and inline uniform
updates in the per-frame stats.
- Hash small texture contents at upload and skip re-uploads whose bytes
are unchanged (removes ~420 redundant uploads per frame in Kokiri
Forest); NX2NAVI_TEX_SAME=0 turns it off.
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>
The README takes the name NX2navi as on GitHub and lists the new upscalers, frame generation, the HDR modes, the
higher resolutions and the shader preparation, and points to the build kit for installing. A crash calling address 0
now logs the return address on top of the stack and its module (it was inside sl.common.dll for Streamline).
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>
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>