The review of 6a0f123a found that nvn_submit_prepare took the same handle given twice in one nvnQueueSubmitCommands
call for another thread's submission in flight: the first occurrence takes the copy's busy count from 0 to 1 and
resolves in place, so nvn_copy_enter returns 0 for the second, and unless the copy was known to hold no deferred binds
(deferred 1; it is 0 at the first submission and 2 for any copy with deferred binds) the call cloned it, a malloc and
copy of up to 1-2 MiB and a pin walk under copy_lock shared. It also logged "thread N submits the recording ... while
another thread's submission of it is replayed", the line the play-test reads to tell whether F-2-03's remainder
happens in this game at all, so a call that repeats a handle would have given a false positive. The asynchronous path
already keeps such a repeat in place (nvn_submit_prepare_async: used_at != now).
Now runtime/nvn_submit_guard.h nvn_copy_again says whether the handle came earlier in the same call and resolves in
place there; nvn_submit_prepare asks it only after nvn_copy_enter found the copy busy and the copy may hold deferred
binds, so only that branch pays for the scan. Such an occurrence stays in place and keeps its busy count, which
nvn_submit_done leaves per occurrence as before. Another thread that submits the copy meanwhile still takes a clone.
tests/submit_clone_check gives the copy twice in a quarter of its calls (76,759 repeats kept in place in 800,000 calls,
no replay saw its handles change, the counts end at 0) and checks nvn_copy_again step by step: given twice with
nothing else in flight, in place both times; with another submission in flight, cloned both times.
docs/building.md says so.
Not reproduced before the change. Checks that passed: submit_clone_check, pass_cc_check of nvn_api_cmd.c and
nvn_api_core.c with no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nvnQueueSubmitCommands resolves a copy's deferred binds at the submit call: the handles the game's arrays hold now are
written into the recording copy (nvn_api_cmd.c resolve_deferred). With the replay on the submitting thread, a second
guest thread that submitted the same copy while the first one replayed it, or while the first copied it for a held
continuation (vk_exec.c cont_new), rewrote those handles under that replay: the rest of it bound the next frame's
textures and samplers, and a held continuation could be copied torn (F-2-03's part left after the private copies).
Now each copy counts the synchronous submissions between their prepare and the end of their replay (copy_prefix.busy,
which shares the 8 bytes pins had, so the prefix keeps its 56 bytes). nvn_submit_prepare takes copy_lock shared once
for all handles: the first submission in flight resolves the copy in place, as before; one that finds another in
flight, unless the copy is known to hold no deferred binds, resolves a clone made under that lock, with its texture
pins counted, and nvn_submit_done frees it after the replay, as the asynchronous path does for a copy a queued
submission still reads. A copy the game releases while a submission of it is in flight becomes a zombie and is freed at
the next sweep after it ends (release_copies, zombie_sweep). The bind loop moved unchanged into
runtime/nvn_submit_guard.h (nvn_resolve_binds) so a check can build it; it reports a corrupt record or a call past 16
deep to resolve_deferred, which ends the run with the messages it had, and keeps the first six changes' trace line.
Being code the front end carries under its MIT header, the new header keeps that line and THIRD_PARTY_NOTICES.md's row
names it. A clone is logged the first four times and at each power of two ("submits the recording ... while another
thread's submission of it is replayed"). No timing change and no wait: the clone path runs only when two threads have
one copy in flight at once.
Not reproduced before the change; nobody knows yet whether the game ever submits one copy from two threads at once (the
new line will say). Checks that passed: the new tests/submit_clone_check (four threads submitting two copies 200,000
times each while their arrays change: no replay saw its handles change, both paths ran, the counts ended at 0; the
unguarded 0.10.1 shape through the same harness: about 450,000 of 800,000 replays saw them change; nvn_resolve_binds
step by step), copy_sweep_check (its stand-in prefix follows the new layout), pass_cc_check on every runtime file, no new
-Wall -Wextra warnings in nvn_api_cmd.c and nvn_api_core.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the NX2NAVI_COPY_RETIRE count found a double count. A record counts as live while copy_find(handle)
succeeds and made_at (nsubmits at copy_add) and the size both match. A copy that AddControlMemory released and the game
recorded again with the same size before the next submission can get the same host block back, so the old record
matched the new copy too: one live copy had two records, and the old one, fingerprinted over the bytes before the
rewrite, counted it under "control memory changed". That breaks the count's stated lower bound.
retire_pass now keeps a map from handle to record index (retire_at, the sweep thread's own) and drops the existing
record of a handle before it appends the new copy's: a newer copy with that handle means the older one was retired.
retire_drop takes a record out of the counts and the map and moves the last record into its place, for both that case
and the visits' removal of copies that are no longer live. Nothing is added to copy_prefix. At the default
copy_retire = 0 the sweep never runs.
Not reproduced before the change in a game run. tests/copy_sweep_check.py queues a copy again with the same handle,
made_at and size over changed control bytes: it must keep one record and leave the changed count. Against the previous
nvn_api_cmd.c it fails with 7 records and copy 2 still counted as changed. The check passes, and the compile check of
nvn_api_cmd.c (also with -Wall -Wextra) adds no warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the NX2NAVI_COPY_RETIRE count found a priority inversion. The "nvn copy sweep" thread ran at the lowest
priority while it held locks the drawing threads take: copy_lock shared for 64 lookups at a time (retire_pass), which
copy_add, release_copies and the submission take exclusive, and later shared takers such as copy_touch queue behind
such a waiter; retire_lock for the queue swap, which retire_queue takes on the recording thread; memory.c's lock through
mem_read_hold and lz_lock through mem_lazy_touches (ctl_fingerprint), which the replay's per-draw check takes. A sweep
preempted inside one of those holds by any thread above the lowest level (the pipeline workers at BELOW_NORMAL are
enough) kept the recording and submitting threads waiting until the scheduler's starvation boost ran it again, which
can take seconds: a new wait on the drawing threads, in the mode the play-test turns on to measure the copies.
retire_main now sets normal priority, the level of the threads it shares the locks with, and keeps it: nearly every step
of a pass holds one of those locks, and a pass is at most 4096 lookups and 16-byte reads every 5 seconds. The block
comment says why. At the default copy_retire = 0 the thread never starts, so nothing changes for players.
tests/copy_sweep_check.py also fails when retire_main lowers the thread below normal priority; against the previous
nvn_api_cmd.c it fails with that message. The check passes, and the compile check of nvn_api_cmd.c (also with -Wall
-Wextra) adds no warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recording copies are retired only when the game adds their control memory again (release_copies), and the fresh
0.10.0 session's "mem: sizes" lines show them growing for good: 220 at frame 600, 223,646 (56.3 MiB) at frame 39600,
with no zombies. This is the first stage of retiring them: it only counts, so the second stage can be judged on
numbers. With copy_retire = 1, copy_add queues each copy's control position, and an "nvn copy sweep" thread at the
lowest priority fingerprints the 16 bytes there within 5 seconds, then visits up to 4096 copies every 5 seconds from a
cursor and counts those no submission used for 5 minutes whose bytes changed or whose memory is no longer mapped. The
"mem: sizes" line gains "of those idle, control memory changed" and "control memory gone". Nothing is freed.
Our driver never writes control memory, so the bytes compared are the game's own leftovers; a reuse that writes the
same bytes is not seen and the counts are a lower bound. Every read of guest memory happens on the sweep thread, never
on a recording thread: a lazily armed range is not read at all, an untouched lazy-heap page is taken as zeros, and the
read holds the mapping with mem_read_hold. The sweep takes copy_lock shared only, 64 lookups at a time, and its queue
has a lock of its own that is never held with copy_lock. Copies that share a control position (EndRecording falls
back to the block's start when it is full) each keep their own record.
The age ring behind "of them unused for 5 minutes" grows from 1024 to 65536 minutes: at 1024 a copy last used 17 hours
earlier shared its slot with the minute now and counted as recent.
New row COPY_RETIRE (0|1, default 0, restart) in known[], sections[] (diagnostics) and metas[], and a copy_retire entry
in nx2navi.ini.example. tests/copy_sweep_check.py cuts the fingerprint, visit, queue and pass functions from
nvn_api_cmd.c with stub copies and stub guest memory: changed bytes count as changed, unmapped memory as gone,
unchanged bytes and copies used within 5 minutes count nothing, armed ranges and untouched pages are never read, retired
and replaced copies leave the counts, and two passes over 5000 copies visit each once or twice. The cfg row check
reports no missing rows, launcher_test passes, and the compile check adds no warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A recording copy is retired only when the game adds its control memory again, and in the 2026-11-13 session the live copies grew from 9,138 (0.7 MiB) to 223,753 (58.3 MiB) in 7 minutes of play, about 38,000 more at each return to an area: the one real host-memory leak of that session. Each copy now carries the minute of its last use (made, called or submitted), and counters by minute give how many copies no submission used for 5 minutes, and their bytes, in the 600-frame sizes line, with no walk of the copies under the lock the recording threads take. Measurement only: retiring such copies needs this figure first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game's line width was dropped at the call and every line drawn 1 pixel wide; the game draws two lines a frame at 4.5 pixels into a 144x144 mask. The width is now recorded with the draw state and set as dynamic state for draws of the line topology class, held to the device's range. Only pipelines of that class list the new dynamic state, so the others keep their entries in the pipeline caches and the driver's. line_width = 0 draws every line 1 pixel wide 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>
Found by profiling a play session and reading the kernel and the command recording:
- nvn_api_cmd.c: a released recording copy had its pins uncounted (a walk over every op) and was freed while copy_lock was held exclusively; the main-loop thread waited for that lock 2.8% of its time. The copies are now chained under the lock and freed after it. The walks over a recording's records test a mask of the five op kinds that name pins before calling op_pins.
- kernel.c: SleepThread(0) took the kernel lock and computed the caller's inherited priority, a scan over every blocked thread, also when no thread waited for a core, which is nearly always. It now yields at once then. A wait without a timeout no longer reads the clock.
- nvn_api_obj.c sleep_until: the wait for a paced frame's slot slept coarsely and left the last 1 to 2 ms to a yield loop; the high-resolution wait now takes all but its own margin.
- input.c: the keyboard scan (a system call per key) is reused for reads within a millisecond.
- nvn_api_core.c: the read-lock observer's commit-point address is 1.2.1's and is now only compared there.
- kernel_probe.c: the probe expected three virtual cores and wait type 3 to be refused; the kernel has six cores and implements the 64-bit wait. It now checks both as they are, counts a core number past the cores as a clash without unbalancing core 0, and --probe-kernel passes again.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
(cherry picked from commit a3dbd63878d7d6de096ceb4c25988192b4bce738)
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>
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>
Small ranges the game invalidates and reads back (the exposure meter) are copied from guest memory into the mirror at every submission that uses their pool, so a CPU write made between recording and the GPU's run is not lost; NX2NAVI_READBACK_SYNC=0 turns it off.
While the boot builds the recorded pipelines a marker file exists next to the pipeline cache. A start that finds it left over records the GPU and driver in pipeline-prewarm-skip.txt, and starts on them build those pipelines at their first draw instead.
CommandBufferWaitEvent is now recorded as OP_WAIT_EVENT. The replay waits for nothing but counts the waits it reaches before their signal, and how many submissions later that signal comes, in the NX2NAVI_TRACE=gfx frame line.
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>