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>
The comment above the mixer interface said every call but open, close and set_running runs with r->lock held, but
audren.c render_thread calls audio_mixer_pace_ns and audio_mixer_owed before it takes the lock, and stats_report calls
audio_mixer_out_stats after it drops it. That is correct, since they touch only atomics and render-thread state
(device_check and channels_switch, the only code that closes and opens the output stream, run on the render thread,
and audio_mixer_close runs after renderer_close joined it), but the comment read as a data race (AUDIT D-08). It now
names the three calls and why they need no lock. Comment only; audio_mixer_check passes and both files compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ov_decode tested v->dav1d without a lock and then queued the sample under v->lock and ran set_live(v, 1). A
DecodeAsync on one guest thread racing Finalize on another could pass the test, wait for the lock while finalize
closed the decoder and cleared the queues, and then queue the sample and mark the finalized decoder live again:
movies_live stayed 1 with no decoder until the destructor or the next Initialize, and frame_rate.c movie_cap_hz kept
the movie's frame rate cap meanwhile.
ov_decode now reads v->dav1d again under v->lock and returns without queueing when it is gone. finalize takes v->lock
after it clears v->dav1d, so the read either sees it set (the sample is queued before finalize's clear_queues, which
drops it, and ov_finalize or the destructor then clears live) or sees NULL.
Not reproduced before the change: the game's own ordering normally keeps DecodeAsync and Finalize apart, and no unit
check exists without dav1d stubs. movie_planes_check passes, and the compile check adds no warnings. The movie
play-test's "frame rate: the movie ended" line after each movie confirms it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
camera_speed.c rewrite reads the guest's heap and alias regions in 16 MiB pieces under mem_read_hold and skipped only
untouched lazy-heap runs. A piece holding pages the lazy write-back had armed (GPU-written buffers the CPU has not
read since) faulted into memory.c lazy_fault on each armed range, which fetches it from the GPU under frz_lock while
the game runs, disarms those buffers, and keeps the game's mapping calls waiting on the read hold meanwhile. This
happened at every change of the stick sensitivity with the lazy write-back on (the Windows default except on AMD's
driver).
A piece that mem_lazy_touches reports armed is now gone through in 64 KiB steps, and the armed steps are not read;
the camera table never lies in a GPU-written buffer. The step after a piece still starts a little before its end, so
a copy across two steps or two pieces is found as before. The first search that leaves memory out logs how much,
since with NX2NAVI_WB_PROBE every range counts as armed and the search then reports the table "not in memory". A
range armed between the check and the read still costs one fetch, as before.
Not reproduced before the change (no log of a sensitivity change under trace). tests/camera_search_check.py cuts
factor and rewrite from camera_speed.c over a 40 MiB stand-in heap with one armed 64 KiB step: the copies in plain
memory, across two pieces and across two steps are rewritten, the copy in armed memory is left alone and no read
touches it, the left-out memory is logged once, and with every range armed nothing is read. Against the previous
camera_speed.c it fails six of its checks. mouse_stick_check passes, and the compile check adds no warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A domain's objects[32] only grew: CloseVirtualHandle returned without freeing anything, so a client that opens and
closes sub-objects (the applet proxy getters, about 17 at boot) would get IPC_R_UNKNOWN_COMMAND for its 33rd, which the
SDK aborts on. The handlers took the new object's id from nobjects, the table had no lock (two threads could race on
nobjects), and a sub-object reached through its own handle (CopyFromCurrentDomain) kept a root pointer that the
domain's last CloseHandle had freed.
The domain table now has a lock in ipc.c (g_domains), taken only for sessions in a domain and never across a handler
or a close callback. ipc_domain_add gives out the first free slot and returns the id the handlers write back; the
table holds one reference on each object and each object one on its domain (held_root), counted in the domain's subs.
CloseVirtualHandle clears the slot and drops the table's reference; it never closes the domain's own id. A request
takes a reference on its target object under the lock for its length, and a cleared id is answered with
IPC_R_BAD_HANDLE instead of being dereferenced; CopyFromCurrentDomain answers the same. When a domain's remaining
references are all its objects', its client has closed it: the table lets the objects go, each object nothing else
holds is freed and lets go of the domain, and the domain is freed with the last one. Domain objects now count in
ipc_sessions_live from the moment they are added, since their last reference counts them out.
Not reproduced before the change: no log here has TRACE=ipc, so the getters per return to the title are not counted.
ipc_session_check gains a domain section: appletOE converted to a domain, 100 rounds of a getter and its
CloseVirtualHandle each get id 3 with the table at 3 slots, a closed id gets a bad-handle result, the domain's own id
survives a CloseVirtualHandle, a proxy with a handle of its own keeps the closed domain until that handle closes and
then both are freed, and closing a domain frees its proxy and five getters. The compile check of every runtime file
passes with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAudioDevice's Acquire...Notification (15, 17) and the virtual device manager's state change event (500) made a new
kernel event at every call and kept its writable handle for good, so a game asking repeatedly took a handle slot each
time (the table holds 4096 and is fatal when full), and past 16 events audio_channels_live no longer signalled the
new ones. The race loser of QueryAudioDeviceSystemEvent (4, 11, 12) also leaked its event.
change_event_of makes one event per kind (the device's, the input and output notifications', the virtual device
manager's) at the kind's first request; each request still gets its own readable handle. The event is listed for
audio_channels_live only by the request that wins the compare-exchange; the loser closes its own event, which was
never listed, so the list never holds a closed handle (closing a listed one would have ended the process at the next
channel change, or signalled whatever event took the number).
Not reproduced before the change: the fresh 0.10.0 session's guest handles stayed at 116-122. audio_mixer_check gains
change_events_once: 60 requests over the four kinds make four events and 60 readable handles, a simulated lost race
closes the loser's event and lists only the winner's, and a channel change signals each listed event once and never a
closed one. The compile check 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>
make_picture allocated the three 16-bit deband planes (6.2 MB at 1080p) for every picture and freed them after it,
60 times a second while a movie played, and in its usual path (8-bit, deband on, no overlay) a failed allocation
ended the game with "movie: out of memory for a WxH frame", while the slower path and the frame allocation itself
drop the frame through movie_oom_log. run_stripes' comment records that FATAL at 45.7 GB of commit on an RX 580.
A verifier's probe measured 0.34-0.36 ms and about 1000 page faults a picture for the per-picture allocation against
0.04 ms and 3 faults with kept planes, about 12% of the convert thread's time per picture.
Each Video now keeps its planes (y16, u16, v16, plane_room), used only by its convert thread and made again only for
a larger picture; planes_for frees what it has and allocates afresh, and on a failure frees all three and keeps none.
Both paths take their planes from it and drop the frame through movie_oom_log when it fails. finalize frees the
planes after stop_thread has joined the convert thread, which also covers the destructor. The path with the deband
off and no overlay no longer allocates planes it never used.
tests/movie_planes_check.py cuts the deband, the conversions, run_stripes, planes_for and make_picture from
ov_movie2.c with a stub dav1d picture and a malloc that fails on request: two pictures of one size allocate the
planes once, a larger one makes them again, the NV12 frame (and the overlay's RGBA frame) equals what fresh planes
give, also with an earlier picture left in the kept planes, and a failed plane allocation drops the frame with
nothing left allocated. The compile check passes with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gfx_vsync_bind kept every SystemEventType address the game ever passed to GetDisplayVsyncEvent, never dropped one, and
called rt_fatal on the 17th distinct address. A destroyed event also stayed on the list the vsync thread signals 60
times a second. kernel.c now exports kern_system_event_live (mapped guest memory with a nonzero state byte), and with
the list full gfx_vsync_bind gives the first slot whose event is no longer live to the new one, which also takes the
destroyed event off the vsync thread's list; it stays fatal only when all 16 are live. Each new listing logs
"gfx: vsync event ... bound in slot N, M listed", so a long session's log shows how often new addresses come.
Not reproduced before the change: the fresh 0.10.0 session's system event count stayed at 9-10 across its area loads,
and no log here covers returns to the title. tests/vsync_bind_check.py cuts gfx_vsync_bind from gfx_present.c with a
stub liveness check: a 17th event takes the first destroyed slot, an 18th the next, and with 16 live events the 17th
still ends with the fatal. system_event_check now also checks kern_system_event_live. The compile check of every
runtime file passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SendSyncRequest looked the session up with kern_handle_get, which holds the kernel lock only for the lookup, and then
dispatched to the handler with no reference. A CloseHandle of the session's last handle on another thread meanwhile
reached ipc_handle_closed, which ran the close callback and freed the session while the handler still used it; for
the audio renderer, renderer_close joins the render thread and frees the renderer a RequestUpdate is reading.
OpenAudioRenderer read the transfer memory the same way, and a CloseHandle frees that object at once.
kernel.c gains kern_handle_hold, which runs a hold callback under the kernel lock after the lookup (handle_close
clears the slot under that lock before it calls ipc_handle_closed, so a close either comes first and the lookup
fails, or comes after and finds the extra reference), and kern_handle_copy, which copies an object under the lock.
ipc_svc takes the session with kern_handle_hold and drops the request's reference through ipc_handle_closed after the
answer, so the close callback runs on whichever thread drops the last reference; the request thread holds no service
lock by then. audren.c copies the transfer memory into a local IpcTransferMemory. Domain sub-objects are not freed
yet (IpcSession.objects), so their lookups are left for the domain object change.
Not reproduced before the change in a game run (no log shows a renderer closed during play). tests/ipc_session_check.py
builds ipc.c with kernel.c linked: a request whose handler blocks while another thread closes the session's only handle
must leave the close callback until the handler returns, and a closed transfer memory handle must copy nothing.
Against the previous ipc.c it fails with the close callback run while the handler was blocked. system_event_check,
sync_wake_check and the compile check of every runtime file pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kern_signal_system_event read the table entry and the state byte without the kernel lock, then kern_event_signal took
the lock and ended the process when the kept writable handle was no longer an event. A host thread (the vsync thread
signals every bound vsync event 60 times a second) racing the guest's DestroySystemEvent, which zeroes the byte and
then closes both handles under the kernel lock, hit that fatal; a probe built on kernel.c reached it after 437
destroy and bind rounds. A stale entry whose byte reads nonzero again ended the process at once, and since h_new hands
a closed number to the next new object, a stale entry could also signal or clear an unrelated event.
Each Event now gets a serial number, and kern_bind_system_event records the serial of the Event its writable handle
names. The new system_event_set reads the entry again under the kernel lock and sets or clears the event only while
the handle still names that same Event; a stale entry is skipped and logged once. kern_clear_system_event goes through
it as well. The lock order is G before g_se_lock; the binder never holds both.
The full-table path of kern_bind_system_event took any entry whose object mem_readable did not know, which includes
the live events host services keep in their own memory (ov_movie2.c Video.in_ev/out_ev, net_offline.c
HostTask.event). The service's next signal from its host thread then found no entry and tried to bind there, which is
fatal. Entries now record whether the object lies in guest memory, and only guest entries that were destroyed or
unmapped give their slot.
tests/system_event_check.py builds kernel.c with stubs and checks a destroyed event with its byte nonzero again, a
closed number taken by an unrelated event, two seconds of a host thread signalling and clearing against a guest
thread destroying and binding again, a full table with the movie's two host-memory events, and the "table full"
fatal with every entry live. Against the previous kernel.c it ends with "kern_event_signal: 0x1001 is not a writable
event handle". sync_wake_check and thread_end_check pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the packaging guard (b4c21998) found that D-1's optional behaviour probe was left out without a reason:
check_built compares only nx2navi_xlate.id with the checkout's sources, so an old exe beside a current .id still
passed, and while the exe build record waits (G-12), a probe of the exe is the only landable check on the exe itself.
It also passes the release scripts' folder, as that translator declares 40960 entries and clamps to 40959.
tools/xlate_smoke.py gains pool_check(exe): it sends a hand-assembled fragment shader that reads a texture handle from
an input (IPA R4 = a[0x80]; TXQ.B R0, R4; EXIT), which the translator reads from the texture pool array, and refuses
an exe that does not run or answer, whose res.json texture pool entry (pool 1) has another array length than
tools/xlate/Program.cs TexturePoolLength, or whose SPIR-V lacks that array or a GLSL.std.450 UMin by length - 1
(spirv_clamps reads the module itself, no disassembler needed). xlate_smoke.py runs it last and takes --exe.
make_launcher.check_xlate runs pool_check on the exe after check_built on Windows, still before anything is
downloaded, built or deleted.
Not reproduced before the change beyond the audit's probe. On this PC, pool_check passes on work\xlate_6866a13e (and
make_launcher.check_xlate on that folder), refuses the 0.9.2 translator (identity 4d051299) with "declares the texture
pool with [32768] entries, where its sources say 40960", also through check_xlate beside a current .id, and
xlate_smoke.py --exe passes on work\xlate_6866a13e. tests/xlate_package_check.py now checks spirv_clamps on a module it
assembles, that pool_check and check_xlate refuse an exe that does not run beside a matching .id, and that a
translator built in build/xlate passes when there is one; it passes. docs/building.md names pool_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the prebuild report's start time (0e1c8339) found that in --prepare-pipelines and --prepare-shaders,
where K.t0 is now seed_pipelines' start, before the shader prewarm, prebuild_report still said "... ms after the device
was ready". seed_pipelines now sets K.prep beside K.t0, and prebuild_report then says "after the preparation started";
in play, where prewarm_run sets K.t0, the line is as before.
Log text only. vk_pipeline.c compiles, with its four -Wall -Wextra warnings and none new; no test or tool reads the line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the torn-file fix (43ac5ae3) found that pb_index also stops at a malformed record in the middle of the
file, so pb_init cuts the file back to it and drops the whole records after it, while its log line and the comment
above the cut spoke only of a record half written. It also found that tests/pb_torn_check.py covered only the index
walk, not the keep-and-cut that is the fix. The decision after the walk moves from pb_init into pb_keep(path, good, len),
which returns the length to go on with (len when the records fill the file, good after cutting it back) or 0 when the
file cannot be cut and starts empty; its log line now reads "<path> ends inside a record or holds a malformed one at
byte <good>; <n> bytes from there dropped", and the comment and the start-empty reason say the same. Behaviour is
unchanged.
pb_torn_check.py now also cuts pb_keep out of vk_pipeline.c and runs it on real files, with plat_file_truncate a
stand-in that cuts with the C library: a torn file is cut to its whole records and logged, a second walk reaches the end
of what is left, a record appended after the cut is read back with the others; a malformed record in the middle is cut
off with what follows; a whole file is left as it is, unlogged; and a file that cannot be cut gives 0 and stays as it
was. The check passes, vk_pipeline.c compiles, and its -Wall -Wextra warnings are the four it had, none in pb_keep or
pb_init. docs/building.md names pb_keep and what the check now covers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the packaging guard (b4c21998) found that the docstring line it edited in tools/make_appimage.py ran to
193 characters, where the rest of the docstring is wrapped at 120; the paragraph also held an older 144-character line.
The paragraph is rewrapped at 120 columns with its text unchanged.
Text only; the file still parses, and tools/check_content.py passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the carry refusal (a2de7eb7) found that gfx_shader.c, which does not include vk.h, defines its own
POOL_TEX_SHORT 32768 beside vk.h's VK_POOL_TEX_CARRIED, and nothing checked that the two stay equal. Should one change
alone, carry_usable would refuse other translations than those vk_texture_register warns about. tests/carry_pool_check.py
now reads both defines and fails, naming both values, when they differ.
Test only. The check passes, and fails with both values named against a copy of vk.h whose VK_POOL_TEX_CARRIED is 32769.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the carry refusal (a2de7eb7) found that the "Translator updates" bullet still said a shader the new
translator has not translated yet uses the previous one's for the whole session, with no wait at the start or in play.
Since that commit, a previous translation whose texture pool array has the earlier length (32768 entries, the 0.9.2
translator's) is not used: such a shader is translated again, in the boot prewarm when the boot names it, otherwise at
first use, with its draws skipped meanwhile and a compute dispatch waiting for the translation. The bullet now says so,
names gfx_shader.c carry_usable, and says that a program the current translator fails keeps even such a translation
(carry_last). The bullet is rewrapped at 120 columns.
Documentation only; tools/check_content.py passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the carry refusal (a2de7eb7) found that it also took the old translation away from programs the current
translator cannot translate. Before it, carry_find served the carried translation before any translation ran; after
it, a carried translation with the earlier texture pool length was refused, the translation ran and failed, and get_one
marked the key failed for the session (xlate_worker marked its wait failed), so the shader's draws were skipped, or a
compute dispatch left out, for the whole session, where 0.10.1 drew them with only the textures numbered from 32768 up
wrong. That contradicted docs/architecture.md ("A program the new translator refuses keeps its old translation") and
the carry pass's "its previous translation stays in use".
gfx_shader.c carry_last now runs where a translation failed (get_one, xlate_worker, and get_async_one's synchronous
fallback): carry_find with the new short_ok argument set passes carry_usable by, so the refused translation enters the
memory cache, where the lookups' memory step serves it from then on; the key is not marked failed and the wait is
cleared as for a success. The first four are logged with their key. Every other caller passes short_ok 0.
pool_short_note still counts such a shader for vk_texture_register's warning but no longer logs it as a translator in
use older than the runtime, and the refusal's log lines say that a shader the current translator fails keeps its old
translation. With this, the carry pass's "its previous translation stays in use" is true again for these programs, and
keeping their previous entries (the pass does not prune a failed input's) is what this needs.
Not reproduced before the change. tests/carry_pool_check.py now also cuts carry_last out of gfx_shader.c and checks that
it serves the refused translation into the memory cache, which the memory lookup then finds, serves a usable one
unlogged, returns NULL with none and still refuses another stage, and that pool_short_note counts a carried shader
without logging it; it passes, every runtime file compiles, and gfx_shader.c has the same 7 -Wall -Wextra warnings as
on stability-pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test expected a store exclusive to fail after another thread changed the value with exclusive pairs and changed it
back (ABA), as the console's monitor would. runtime/cpu.h's monitor (EXCL ldxN/stxN, ldxp128/stxp128) compares the
memory with the value loaded, so a value restored to the same bits passes by design, and the test has failed since
both came in with the base import. It now checks both cases the helpers define: one change fails the store (status
1), a change and its undoing does not (status 0), for the 32-bit, 64-bit and 128-bit forms, with the control and the
contention runs as before. Its docstring, a comment and docs/building.md's row say that the monitor does not see the
change-and-restore case. No runtime change.
The test passes under .venv: the one-change cases fail the store, the two-change cases succeed, and every contention
counter is exact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
recomp/patches-v111.txt puts the two movie timestamp sites (main+0x3b5c7c4 and main+0x3b5c908) in set omovie,
switched by NX2NAVI_MOVIE_PTS, since they were given a switch of their own; the check still asserted set ostep
(NX2NAVI_OCARINA_REALTIME) and failed before comparing anything. It now asserts omovie. The patch words are unchanged.
The check passes again under .venv with the ucrt64 clang: 3114 C/Unicorn comparisons across both patch sites.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since cd0b1a38, vk_pipeline.c lb_next calls lb_idle() and stops a boot builder at or above LB.bg_threads while the
builders run beside the game, so the check, which builds lb_next against a stand-in LB, failed to compile ("call to
undeclared function 'lb_idle'", "no member named 'bg_threads'"). The stand-in LB has bg_threads, and lb_idle copies
the real one with a test switch for bf_waiting. The concurrent runs now also cover two background workers and a start
that waits for the whole warm, and the explicit cases check that worker 2 of 2 stops beside the game and goes on in a
preparation. vk_pipeline.c is unchanged.
The check passes again with the ucrt64 clang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since d9429b0c, vk_pipeline.c cache_save_one stores the cache data sizes in P.held for the memory report, and the
check, which builds cache_save_one against a stand-in P, failed to compile ("no member named 'held'"), so the exit
save's snapshot rules went unchecked. The stand-in has the field now; vk_pipeline.c is unchanged.
The check passes again with the ucrt64 clang: builds held back and waited for, autosave unchanged, timeout, failures,
merges and the size limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nx2navi.ini.example says tex_vram_pressure = 0 means the textures never give way for video memory, and vk_texture.c
sweep_pressure reads 0 as off, but pressure_release mapped 0 to the default 80 %, so the pipelines no draw had used
were still given back over 80 % of the budget. pressure_release now leaves them for the run at 0 and logs so; values
outside 0..100 still fall back to 80. The pipe_release paragraph of nx2navi.ini.example says that tex_vram_pressure = 0
turns it off too. No cfg.c row changes.
Not reproduced before the change; nothing changes at the default (80). vk_pipeline.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ready_note records a pipeline in pipeline-keys.bin at its fast link from p->job's state. When the pool's whole build
of a pipeline failed before its queued fast link ran, job_build had cleared p->job and recorded nothing (it records
only a built pipeline), so u_run's link made the pipeline usable without a record, and every later start linked it
again at its first draw. graphics()' own link has the same gap, since gpl_link releases P.lock. ready_note now takes
the state each caller built the pipeline from (u_run the LibJob's, lib_worker, pipe_worker, job_build and
warm_builder their job's, graphics() its own) and records from it when the job is gone; a prebuild job's link is still
not recorded. keys_note takes each record once, under P.lock, which every caller holds.
Not reproduced before the change (it needs a failed optimised build). vk_pipeline.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NX2NAVI_PIPE_BINARIES=1, a process ended during the autosave's append (a kill, a crash, a fatal on another
thread) or a disk that filled left the file ending inside a record, and pb_init then started empty, dropping every
whole record (up to the 1 GB limit). After a failed append pb_flush also left the torn bytes in the file while
file_size stayed, so every later record's offset was off by them. The index walk moves into pb_index, which returns
where the last whole record ends; pb_init keeps the index of those records and cuts the file back to that offset with
plat_file_truncate after it closed its handle, and starts empty only when the cut fails. pb_flush closes the read
handle, cuts the file back to file_size and opens it again when an append fails (plat_file_truncate opens the file
alone, so it fails while another handle is open); pb_read refuses to read when that reopen failed.
Not reproduced before the change; the option is off by default. tests/pb_torn_check.py (new) cuts the record layout
and pb_index out of vk_pipeline.c and walks files of whole records, with a last record cut short at many lengths and
with a malformed record; vk_pipeline.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
link_queue puts a draw's fast link on the library queue as a LibJob with p set and no library (b NULL); lib_worker and
pipe_worker branch on that, but workers_drain cleared j->b->job for every queued job, a NULL dereference when
vk_pipeline_shutdown found one. It now clears the library's fields only when the job has one and clears the
pipeline's link_queued when it names a pipeline. Only the probes shut the pipelines down (mesh_fb_probe.c,
vk_exec_probe.c); the game never does.
Not reproduced before the change (the probes need approval). vk_pipeline.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In --prepare-pipelines and --prepare-shaders, a pool worker that finishes the last listed job calls prebuild_report,
which logs now_ms() - K.t0; only prewarm_run, which these modes never run, set K.t0, so the line gave the time since
the PC booted. seed_pipelines now sets K.t0 to its own start under P.lock beside K.loading. That start comes before
the shader prewarm, so the figure includes the translation.
Not reproduced before the change (no preparation log here has the line: only a pool worker ending the list writes
it). vk_pipeline.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shader whose texture pool array has 32768 entries reads slot 32767 for every texture the game numbers from 32768 up
(Ocarina's pool has 33068), but nothing in the log said such a shader was in use: vk_texture_register warned only for
slots past VK_POOL_TEX (40960), which Ocarina never registers, and vk_pipeline.c spirv_pool_rebind accepts the shorter
arrays silently. gfx_shader.c pool_short_note now counts the shaders entering the memory cache with such an array
(carry_usable keeps carried ones out, so these come from a translator older than the runtime) and logs the first;
vk_texture_register logs once, with that count, when a slot between 32768 and 40959 is registered while one is in
use. The count is kept per shader in gfx_shader.c (gfx_shader_pool_short_seen) rather than per pipeline stage in
spirv_pool_rebind, which runs once per module built.
Diagnostics only. tests/carry_pool_check.py now also checks that pool_short_note counts only shaders of the earlier
length and logs once; every runtime file compiles, with no new warnings in gfx_shader.c and vk_texture.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pressure_release queued up to 256 released_destroy_cb callbacks at one timeline value every 5 s, and completion_main
runs every callback at or below the reached value before it counts the value as done and wakes the waiters. The
game's fence waits, the frame's first-submission wait and the acquire wait therefore sat behind up to 256
vkDestroyPipeline calls. The released entries now go on a list the autosave thread alone owns, each with the value
the GPU must be past (a field of its own, rel_fence), and release_drain at the start of every autosave tick destroys
at most 64 of those that are done, none after a device loss. 64 per 500 ms outpaces the 256 per 5 s a pass takes out.
The callback is gone.
Not reproduced before the change (the cost of vkDestroyPipeline per call was not measured). vk_pipeline.c compiles
with no new warnings. It adds no wait to a drawing thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pressure_release read the driver's figure (vk_mem_vram), which counts the free space inside VMA's blocks as used.
While those blocks held holes the use stayed over tex_vram_pressure however many pipelines went, so every 5 s another
batch of up to 256 prebuilt pipelines no draw had used yet was destroyed until none was left, and each later first
visit missed them again. The texture and mirror evictions moved to vk_mem_vram_live for the same reason; the
pipelines now use it too, and the comment and both log lines say that the free space inside the blocks is not
counted. The comment above vk_pipeline_sizes no longer says the table's entries are never taken out.
Not reproduced before the change (no available log reaches the threshold). vk_pipeline.c compiles with no new
warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_xlate.py --id-only wrote an nx2navi_xlate.id for whatever exe sat in the folder, from the sources as they are,
so make_launcher's guard (check_xlate, which compares only the .id) passed an old exe beside a fresh .id. Nothing
calls the option; it is gone, and only a build writes an .id. make_release.check_translator compared build/xlate's .id
with build_xlate.identity(), which hashes the tree the last build prepared there, so a patch or vendor change since
that build passed unseen; make_appimage.py and make_kit.py checked nothing. The fresh-prepare identity moves from
make_launcher into build_xlate as source_identity(), and build_xlate.check_built(folder, exe) refuses a missing exe or
.id and an .id that differs from it. make_launcher, make_release, make_appimage and make_kit stop on its reason, each
before its downloads, builds or deletions: make_launcher's check moves from build_app to the top of main, before the
--out folder is deleted and the toolchain lock is resolved. A translator folder built from the current sources, as the
release scripts pass it (work\xlate_6866a13e), passes as before.
Not reproduced before the change beyond the audit's probes. tests/xlate_package_check.py (new) checks a matching
folder, missing files, another identity and a stale build (refused although identity() over its own tree equals its
.id), that --id-only is refused, and that make_launcher, make_release and make_kit stop on a foreign translator, the
kit before any download; check_built passes on work\xlate_6866a13e.
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>
Installs updated from 0.10.0 or 0.10.1 hold translations of the 0.9.2 translator, which declares the texture pool with
32768 entries and clamps every handle to slot 32767. The carry lookups (gfx_shader.c carry_load, carry_find_iid) served
such a translation for the whole session, so every texture the game numbers from 32768 up (Ocarina's pool has 33068)
was drawn with whatever slot 32767 held: the see-through ground of issues #569 and #824 stayed on those installs after
the update. carry_usable now refuses a previous translator's translation that has a texture pool array (reflection
pool 1) of 32768 entries; the lookup goes on to the next previous identity and then to a translation under the current
translator, so the boot prewarm translates the shaders it names on the boot screen and the rest are translated at first
use. The first refusal is logged, and the saves and the exit log how many there were. get_one now checks its failed
keys before the previous translators' disk entries, so a shader whose translation failed does not read a refused entry
from the disk at every call.
Not reproduced before the change. tests/carry_pool_check.py (new) cuts the carry lookups out of gfx_shader.c and checks
that an earlier-length translation is freed and passed over, also in the owned branch, while others are served;
gfx_shader.c compiles with no new warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that astc_trim_check, evo_peek_check, heap_lazy_copy_check and gen_wrap_check were in neither the
pass's test matrix nor the docs, so a later change to the code they cover would not know to run them. Each now has a
sentence in the "A change to X passes python tests\Y.py" form: vk_astc.c's scratch trim with vk_defer_buffer_try,
vk_evo_peek.h with vk_exec.c vk_exec_held_ready, memory.c mem_copy_readable with the lazy heap, and vk_memory.c's page
write generations.
Docs only. Checks that passed: check_content --staged; the four checks pass with system Python.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the B-4 change found that nothing measured the pool destroys any more. Before it, pool_retire ran
destroy_rec inside a completion callback, which trace=perf timed per callback; now pool_dead_drain runs the destroys
untimed, and the line only counted them. The drain also runs on whichever collect swaps the list out first, so part of
the work can fall on the game's thread in pool_fini's vk_mem_collect, and the play-test the finding plans could not tell
where the cost went.
pool_dead_drain now times its destroy loop with plat_now_ns and keeps the records, their time and the longest drain
apart for the completion thread's vk_mem_collect_try and for vk_mem_collect, whose only caller is pool_fini. The trace=perf
"mem: sizes" GPU line gives them since the last line: "of them since the last line: N on the completion thread (X ms,
longest drain Y ms), M by pool_fini (X ms, longest drain Y ms)". Diagnostics only: two clock reads per drain that
destroys something, and no lock or wait added.
Checks that passed: pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings, sync_hw_check, gpa_check,
mem_budget_check, tex_release_check, wb_lazy_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sweep_pressure's second census, rel_census(cap, 1), ran whenever the released entries held more than their bound and
less than 1.25 times what they may keep. Its line says "(kept past it while the card has room)". Without room, or
without the hold, vk_tex_release.h trl_keep returns the bound itself, so keep equals cap. The census then also ran
for bytes between the bound and 1.25 times it, which drop_stale was taking down anyway, and the label claimed room the
card did not have. Now it runs only when keep > cap. The line stays for players: it is what tells why released
textures stay past their bound (a 2026-11-13 session held 7,150 MiB against 4,008 and never said why), and at most one
walk a minute, 60 a run, costs well under a millisecond. rel_census's comment says when the room census runs.
Checks that passed: pass_cc_check on vk_texture.c, no new -Wall -Wextra warnings, tex_release_check (trl_keep's rule).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Past RELEASED_HARD, sweep_hard walks every released entry (up to 16k), classes each, and drops up to HARD_BATCH of them
in one call, under the texture lock, on the thread that releases (a guest thread's finalize or the replay's
overwrite_others). The rule before dropped one entry per release, so the work now comes in bursts, and nothing
measured a burst.
Each call is now timed, and counts the entries it walked, the entries it dropped and the entries written back first.
A call of 2 ms or more logs "released textures past the hard count: a batch walked N entries and dropped M (K written
back) in X ms", 16 lines at most. The trace=perf "texture origins" line adds the calls of its window, their time and
the longest. The behaviour changes the finding proposes after a measurement (a cap on the GPU class in trl_hard_pick)
are not part of this commit.
Diagnostics only. Checks that passed: pass_cc_check on vk_texture.c, no new -Wall -Wextra warnings,
tex_release_check, tex_compact_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_memory.c hands out page write generations (gen_now, 32 bits) with no wrap handling. Past the wrap, textures and
pool mirrors the CPU writes can miss a write, and every check reads as a write. At about 181 generations a frame that
takes about 110 h at 60 fps or 27 h at 240. Only trace=perf's "mem: sizes" counted them, so nothing in a player's log
could tell a "textures went stale after a day" report from other causes.
The nine "++gen_now" sites now call gen_next, which hands out the next generation (gen_lock held exclusively, as at
every site). When the value reaches 0xC0000000, 0xD0000000, 0xE0000000 or 0xF0000000 it logs the count, the frame,
the rate per frame and about how many hours of play are left at that rate. At the wrap it logs that the generations
ran out. That is five lines at most a session and one predicted branch per generation. The log queue takes no lock
of vk_memory.c, so logging under gen_lock is safe. The wrap handling itself waits for approval.
The new tests/gen_wrap_check extracts the section from the "page write generations" banner to the "dirty census"
banner and runs it with stubs. It checks one line at each mark and at the wrap and none elsewhere, and the first
line's rate and hours. It also expects the four comparisons that are known to answer wrong across the wrap, and says
so: the change that handles the wrap turns them into failures there and flips them. On the code before this change
the check fails (no lines). Checks that passed: gen_wrap_check, pass_cc_check on vk_memory.c, no new -Wall -Wextra
warnings, gpa_check, mem_growth_check, mem_budget_check, wb_lazy_check, tex_release_check.
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>
vk_gpu_parts_log (trace=perf, a frame whose render thread waited 20 ms or more for fences) names the longest GPU pieces
by kind, "uploads" and "present" included. But its ring was filled only in ts_read_cb, with the replay's parts, mirror
prologues and pre-pass buffers. Upload batches (vk_memory.c up_ts_read) and presents (gfx_present.c pts_read) reach
vk_gtl_note_ns and never the ring. So the line could not show the ASTC decodes of the upload batches after an area
load, the frames it was made for.
The ring append moves into vk_gtl_note_ns, under gtl_lock, so every kind enters. Uploads are noted when their batch
retires and presents when their slot is used again, so the entries no longer come in GPU order. The reader copies
those that ended in the frame's interval and sorts them by GPU start before it computes the longest gap. PART_RING
grows from 512 to 2048, since an area load adds hundreds of upload entries and the reader takes the newest half. The
time "in the queue" is now counted from the hand-over to the queue for every kind, which for a replay part held by
the submit batching is the batch's hand-over rather than its submit call. The comment says so.
Diagnostics only (trace=perf). Checks that passed: pass_cc_check on vk_sync.c, no new -Wall -Wextra warnings,
sync_hw_check, stutter_hitch_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With lock_time = 1 the 0.10.0 session showed flip_locked waiting up to 48.61 ms for the queue lock, and the replay's
queue_submit up to 8.05 ms. Nothing named who held it: only vk_texture.c's lock records its holders. A swapchain
rebuild holds the lock across vkDeviceWaitIdle and the create, and that hold was not timed either.
vk_queue_lock, vk_queue_lock_raw and vk_queue_trylock_raw are no longer inlined. Each takes the lock with
plat_rmutex_lock_at and its own return address, so the wait report keeps naming the caller (before, the inlined call
named a site inside flip_locked). At the outermost take, with lock_time on, the holder's site and the time are noted.
vk_queue_unlock passes a hold of 5 ms or more to perf.c perf_qlock_hold. The frame-time summary then prints "queue lock
held 5 ms or more" beside the texture lock's holds, by site, as the waits are. create_swapchain logs how long a
successful rebuild took: the wait for the lock, the hold, and the part spent waiting for the device to idle. It logs
the first 16 rebuilds and each doubling of their count.
Diagnostics only: the counters are behind NX2NAVI_LOCK_TIME, which is off by default, plus one capped line per
rebuild. Checks that passed: pass_cc_check on every runtime file (host.h changed), no new -Wall -Wextra warnings in
gfx_present.c and perf.c, stutter_hitch_check, pgo_build_test, sync_hw_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
present_image's 5 s wait for the slot's fence passed its result to nothing but a "present fence wait timed out" line.
A lost device was reported as a timeout, and since a lost device's wait returns at once, the line repeated at the
game's present rate until another thread noticed the loss. A GPU hung but not lost repeated the line every 5 s with no
cap. The result now goes to vk_device_lost_check first. The line says whether the wait timed out or failed, with its
code and count, for the first 8 times and at each doubling.
An acquire answering an error other than OUT_OF_DATE (VK_ERROR_SURFACE_LOST_KHR, say) skipped the frame without a
word. Each such code is now logged once. Making the surface again would be presentation and device work and is left
alone.
The capture path waited for the slot's fence and then read the read-back buffer whatever the wait returned:
save_capture and grab_deliver could copy a frame the GPU had not finished. A failed wait now goes to
vk_device_lost_check, marks no capture valid, and ends a pending grab with no pixels, as the no-buffer path does.
Not reproduced before the change: the fresh logs have no such line. Checks that passed: pass_cc_check on
gfx_present.c, no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under VRAM pressure or a standing mirror room request, sweep_pressure runs every eighth submission. evict_idle walked
the whole texture table and, when it found no idle candidate, returned before it set evict_wait, so the next check
walked it all again. The released-list loop before it started from the head each time and passed every stale entry it
could not drop (up to RELEASED_HARD) until PRESSURE_STEP drops.
evict_idle now keeps a wait of its own after an empty walk: no new walk for EVICT_EMPTY_WAIT (16) submissions. It is
a separate idle_empty_wait, because evict_disused, which sweep_pressure calls right after evict_idle when it freed too
little, stops while evict_wait is ahead, and sharing that wait would have stopped disuse eviction for as long as the
idle walk stayed empty. The released loop looks at up to EVICT_LOOK (1024) entries a check from a cursor (press_at)
that rel_unlink moves off a leaving entry, as for sweep_at and aged_at. It starts from the oldest again at the first
entry released less than STALE_FRAMES ago or at the list's end. The trace=perf eviction line counts the empty walks
and their time.
Not reproduced before the change: the 0.10.0 session had no VRAM pressure. Checks that passed: pass_cc_check on
vk_texture.c, no new -Wall -Wextra warnings, tex_release_check, tex_compact_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Past RELEASED_HARD, sweep_hard drops up to HARD_BATCH released entries in one walk (2969005e). drop_tex writes a
gpu_dirty entry back for DROP_HARD when the fence write-back would take it, and each write_back_n(&e, 1) call ended
in vk_upload_flush(1), a GPU round trip. A batch with many such entries waited once per entry, under tex_lock, on the
thread that releases, the replay's overwrite_others included. The rule before dropped one entry per release, so it
waited at most once.
Now sweep_hard gathers the picked entries the same way drop_tex decides on them: an entry whose storage the CPU
rewrote since the GPU drew into it loses gpu_dirty, and one wb_wanted takes is written back. All of them go back in
one write_back_n call, one upload batch with one wait. write_back_n clears gpu_dirty, so drop_tex then finds nothing
to write back. The same entries go, in the same order, and the entries written back are counted in n_drop_wb as
before.
Not reproduced before the change: the 0.10.0 session never reached RELEASED_HARD, and no check without a device
covers write_back_n. Checks that passed: pass_cc_check on vk_texture.c, no new -Wall -Wextra warnings,
tex_release_check, tex_compact_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
completion_main calls vk_mem_collect after it publishes each new done, and vk_mem_collect waited for cmd_lock. An
upload flush holds that lock across vk_queue_lock, behind a present call (FIFO, a frame limiter inside the present,
frame generation) or another long queue-lock hold. With wait = 1 it also holds it across its batches' fence waits,
and stg_create waits for batch fences under it. The single completion thread blocked there could not process the next
timeline value, so the next done, and every fence waiter for it, waited for that chain.
vk_mem_collect_try only tries cmd_lock. When the lock is busy it still destroys the finalized pools, which need only
retire_lock, and leaves the deferred objects to a later completion or to the next upload flush's own collect. After
eight busy tries in a row it waits as vk_mem_collect does, which keeps the deferred list bounded, as before. pool_fini
keeps the waiting vk_mem_collect. The trace=perf "mem: sizes" GPU line counts the collects that gave way.
Not reproduced before the change: the completion thread's lock waits are not timed. Checks that passed:
pass_cc_check on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_memory.c and vk_sync.c,
sync_hw_check, gpa_check, mem_budget_check, tex_release_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pool_retire destroyed the record at once (destroy_rec: vmaDestroyBuffer of the mirror, vkDestroyBuffer and
vkFreeMemory of the imported memory, which the driver unpins). It ran in two places, and both were costly. In the
completion thread's callbacks it ran before done is published, so every fence waiter waited for those frees: 717.7 ms
in 7,250 calls in the 0.10.0 session, up to 76.8 ms within one 240-submission window at an area change. From
pool_wait_collect it ran under cmd_lock, in the upload flush of whichever thread collected first, such as the replay's
per-submission vk_upload_flush or the game's presenting thread. Every upload and deferral waited on cmd_lock
meanwhile, and a slow frame showed the time as unattributed.
Now pool_retire only moves the record from the retiring list to a new pool_dead list (retire_lock). vk_mem_collect
alone drains that list, after its cmd_lock section and holding no lock. The completion thread calls vk_mem_collect
right after it publishes done and wakes the waiters, and pool_fini calls it for its own frees. collect_locked
(vk_upload_flush, shutdown) never drains the list. vk_mem_shutdown destroys what is left on it, after its
vkDeviceWaitIdle. The trace=perf "mem: sizes" GPU line counts the records destroyed this way.
Not reproduced before the change: the cost was summed from the completion thread's per-callback lines of the 0.10.0
log, not measured on a frame. Checks that passed: pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings,
gpa_check, mem_budget_check, tex_release_check, sync_hw_check, wb_lazy_check, mem_growth_check.
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>
mirror_create put the pool on mlist under pool_lock, released the lock, and only then set acc_sub, mstate = MS_ON,
asked_room and twin. It also took pool_lock again for the device address table's hand-over (gpa_flip) or, with
NX2NAVI_GPA_LATE=0, the remap. pool_fini calls mirror_forget only when it finds MS_ON. A finalize on another thread
in that gap therefore left the record on mlist, and destroy_rec freed it and its buffer without taking it off the
list. The next hot_check, mirror_evict, vk_mem_mirror_begin or gpa_flip_cmds then read freed memory, and
mirror_evict could defer the freed buffer a second time. With GPA_LATE=0, the table record then named the destroyed
buffer. Now one hold covers the finalize check, the insertion, acc_sub then MS_ON, asked_room, twin, and the
hand-over or remap. twin_wanted and gpa_late are decided before the lock is taken, since each may read its setting and
log once. The untwinned counters are atomics and stay after the hold.
Not reproduced before the change: no check without a device can drive the race. Checks that passed: pass_cc_check on
vk_memory.c, no new -Wall -Wextra warnings, wb_lazy_check, gpa_check, mem_growth_check, mem_budget_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>