Commit Graph

1187 Commits

Author SHA1 Message Date
chrg6
0fbd2b8f31 Add a static check of cfg.c's settings tables to the content guard, and list the 15 names it found missing from known[].
A // on a metas[] line hides the rows after it on that line, and the game then ends at start with "FATAL: cfg:
NX2NAVI_<X> has no row in the settings table"; that shipped in two test builds and nothing caught it before a
start. And 15 names the runtime reads were not in known[], so setting any of them in nx2navi.ini logged "unknown
key" and put it in the menu's list of unknown keys although it took effect: AUDIO_DUMP, DEFER_STAMP, DEPTH_TWIN,
DLAA_ZFIT, DUMP_SKIP_RUNS, DUMP_TEX, DUMP_UBO, EVENT_ORDER, GATHER_BIAS, PIN_SWEEP, REC_CHECK, REC_DELAY_US,
REG_ORDER, SHADOW and ZERO_LOCALS (GATHER_BIAS and ZERO_LOCALS through vk_caps.c env_u32).

tests/cfg_rows_check.py (standard library only) parses known[], metas[], sections[], DECK[] and LOWEND[] by
brace matching that skips string and character literals and comments (an apostrophe in a comment inside a table
would otherwise open a character literal and run the table on past its end), line by line with the first // outside
a string split off, and the first #if branch standing for the table. It fails on a row or name after //, a /* in a table, a name twice in known[], two metas[] rows for a
key, a metas[] row not in known[], a cfg_value/cfg_int/cfg_bool/cfg_double read (directly or through one level of
wrapper) with no row and no cfg_get, cfg_is_set or cfg_meta guard earlier in the same function (kernel.c SPIN and
SVC_WAITS, vk_headroom.c VRAM_HEADROOM pass that way), and a name read through cfg_get, cfg_is_set, cfg_meta,
rt_knob, a cfg_get or "NX2NAVI_%s" wrapper, or a patches-v111.txt set line that is not in known[] or a cheat.
--self-test puts each kind of fault into a copy and checks it is caught. The content guard workflow runs the
check after tools/check_content.py. The 15 names go into known[] on a line of their own with no //.

Reproduced before the change: the check reported the 15 names on stability-pass, as the audit's prototype did.
Now it reports 0 problems and its self-test catches all 15 injected faults; the scratchpad cfg rows check
("missing []"), cfg_parse_check and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
bbea3ce2a0 Write the end of the run to the log file past its 64 MiB cap, up to a 4 MiB reserve counted on its own.
main.c log_sink wrote nothing to the file once g_log_bytes passed LOG_CAP: every later line, the exit summary,
"frametime: exit", the stutter map's exit lines and FATAL and crash lines included, went to stderr only, which a
game started from the launcher or Explorer does not keep. A trace = perf session reaches the cap in about 90
minutes (the fresh 0.10.0 log grew 12 KB/s), and the play-test asks for an hour or more of exactly that. A reserve
compared against g_log_bytes would not help: that counter adds every line, dropped ones too, so 4 MiB of dropped
lines (under 6 minutes at that rate) would use it up before the end came.

The new rt_log_ending marks the process as ending; rt_exit_begin (the orderly ends) and perf_summary_flush (the
frame-time summary, also on the cheats rebuild exit) call it, and rt_log_sync (a fatal error, the crash report)
counts as ending too. The exit summary and the stutter map are written before rt_log_sync, so keying on
g_log_final alone would have missed them. While ending, a line past the cap goes to the file through
g_log_tail, a counter of the bytes written past the cap, up to LOG_END_RESERVE (4 MiB), after one line saying the
end of the run follows. The cut line says the end will still come. The file can end 4 MiB past the cap.

perf_summary_flush now calls rt_log_ending before ft_stop, so tests/exit_order_check.py's static check of it wants
ft_stop before ft_report rather than first. The check also prints one closing line for the static checks and the
program together: the program's own "ok" line had hidden a failing static check.

Not reproduced before the change (no log here reached the cap). The new tests/log_cap_check.py builds log_sink
with a 4 KiB cap and a 2 KiB reserve: past the cap ordinary lines are dropped with one cut line, and after
rt_log_ending or rt_log_sync the end's lines fill the reserve after one "end follows" line, however much was
dropped before. exit_order_check (rt_exit_begin marks the end once; exit code 0 at this commit), logq_check and the
compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
8c1ec42406 Log the nx2navi.ini and environment values that are outside their settings' sets, with the line they came from.
cfg.c takes a value as written and logged nothing about it: "pace_smooth = 0 ; off" keeps the comment in the value
and cfg_bool reads it as on, "fullscreen = false" is on too, and "sharpness = 250" is past 0..100 (the audit's
probe on cfg.c printed exactly that, with 0 log lines during the load). A player who edits the file by hand had no
way to see why a setting did not take.

After cfg_load (and after migrate, so a value it rewrites is checked as read) each file value is checked against
its row: a 0|1 switch with any other value, a value that is not a number in a lo..hi range, and a word that is
none of a list of words, case aside. A list with a number in it is left alone: it is the menu's presets and its
consumer takes more (fps_limit any rate from 30 to 240). A list of words is reported as "none of the menu's
choices" only, since a consumer may take more (dlss_preset a..m). A value holding " ;" or " #" is noted on any row:
the comment is part of it. cfg_init checks the environment's NX2NAVI_ values the same way. At most 16 lines a load,
then a count. The values read are unchanged; reading words for off or cutting comments (F-8) needs approval.

Not reproduced before the change by a game run; the new tests/cfg_parse_check.py builds cfg.c with stand-ins and
checks each kind of line with its line number, the environment's, the cap of 16 and a count, and no line for a file
whose values fit (including fps_limit 144, render_scale 300, dlss_preset k, pad_layout xbox_prompts). Every value
nx2navi.ini.example shows passes without a line. launcher_test PlayerIniTest, the cfg rows check and the compile
checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
87fd385b7b Stop HD rumble at a fatal error and a crash too, on a thread of its own the end waits for 300 ms at most.
input_rumble_hd_stop ran only in the orderly ends, so a FATAL or a crash while a controller vibrated on HD rumble
left it buzzing: nothing renews or stops the value after the process is gone (input.c says so). It also took
hd_lock with a blocking lock while it only tried the other two.

dispatch.c rt_rumble_stop_bounded runs input_rumble_hd_stop (weak in dispatch.c, as fs_save_crash_wait is, for
builds without input.c) on a new "rumble stop" thread and waits at most 300 ms for it, never joining it: the stop
goes through SDL, which takes its own joystick lock, and a crash on SDL's thread while it held that lock would
otherwise keep the ending thread there and the process would never end. fatal_end and main.c crash_handler call
it right after rtss_limit_restore. input.c tries hd_lock 50 times 1 ms apart, as it does `lock` and sdl_lock, and
skips the stop when it is not taken.

Not reproduced before the change. tests/fatal_exit_check.py (now linking dispatch.c as its own unit: the stop runs
at a fatal error; a stop that hangs leaves the end at code 70 after 0.3 s; statically both failed ends call the
bounded stop after the RTSS restore), tests/rumble_hd_check.py and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
722a5dd1b0 Keep 32 KiB of every thread's and fiber's stack back for a stack overflow's crash report.
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>
2026-11-13 18:42:10 -05:00
chrg6
790d201a59 Keep a save file open until the last call through its handle is out, so a CloseFile beside a read or write cannot redirect it.
fs_host.c file_of copied the slot's host file under the shared lock and let go of the lock, and read_file,
write_file, file_size, set_file_size and flush_file then used it with no lock, while close_file on another thread
cleared the slot and closed the file at once. Windows hands a closed HANDLE's value to the next file opened (the
audit's probe got it back in 100 of 100 rounds and read the other file through the stale value), so a call under
way went on in another file: a save write could land in the wrong one. Only the game's own misuse of a handle
reaches it; this is the open file half of AUDIT's F-1-09.

Each call now counts itself in its slot from file_of to a new file_release. close_file closes at once when no
call is under way and otherwise marks the slot closing; the last call out closes the file (file_closed, with the
save bookkeeping close_file had). Nothing waits for another call, the slot and so the guest's handle value is not
handed out again before the close, and a call after the CloseFile gets FS_R_INVALID_ARGUMENT, as before for a
closed handle. Holding the lock across the I/O was not taken: opens and closes on every guest thread would then
wait for save reads and writes, and write_file's save_touched can wait for a Commit.

Reproduced before the change with stand-in host files that reuse a closed handle's value: the new
tests/fs_file_use_check.py built against the old functions closed during a read, gave the next open the same
handle, and the read returned the other file's bytes. With the change it passes (a close during a read happens
after it, once; a call after the close is refused; 20000 close-and-open rounds beside a reading thread read no
foreign bytes), as do save_share_check, save_retry_check, save_zip_check and the compile checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
1352b14f64 Let the first failure report end the run whole when another thread crashes or hits a fatal error beside it.
The platform layer calls the crash handler on every thread that faults, without waiting for the others
(win32.c crash_veh, crash_filter), and main.c crash_handler ended any second entry with plat_exit(71) at once.
So a second fault during the first report cut it after its first lines, together with its wait for a save Commit
(fs_save_crash_wait) and RivaTuner's restore; the audit's probe showed it in 3 of 3 runs. fatal_end had no guard
at all and none shared with the crash report: a crash during a FATAL's end wrote a whole crash report whose
"FATAL: access violation" became the log's last FATAL line (the one the launcher shows) and replaced code 70 with
71, and a FATAL during a crash report cut it with code 70.

dispatch.c rt_ending_enter is the one guard for both ends: the first caller reports and ends the process; the
same thread again (a failure inside its own report) ends at once with its own code (crash_handler keeps
plat_exit(71), fatal_end plat_terminate(code)); another thread waits in 10 ms sleeps, logging nothing since it
may hold the log queue's lock, until the process ends, and after 30 s ends it with 71 should the first report
hang. fatal_end calls it first; crash_handler calls it in place of its own busy flag.

Reproduced before the change by tests/fatal_exit_check.py's new cases, which failed on the old guards: two
faults at once (report lines 2 and 3 missing), a fault during rt_fatal (a crash report after the FATAL) and
rt_fatal during a crash report (exit 70, the report cut). They pass now, with the earlier cases and the static
check that crash_handler starts with the shared guard; the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
8f952d2841 End fatal errors and a lost device with TerminateProcess instead of _Exit, which ran every library's detach.
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>
2026-11-13 18:42:10 -05:00
chrg6
e39f71df39 Run every orderly exit through one sequence that puts RivaTuner's profile back before the slow pipeline save.
The window's close, the guest's ExitProcess and the end of a --seconds run each ran their own list. All three
waited for the pipeline save (up to 5 s for running builds, then the write) before they put RivaTuner Statistics
Server's profile back, stopped HD rumble, wrote the frame-time summary and flushed the save, so a console close,
a session end or an "End task" that killed the process in that wait left the game's limit in RTSS's profile (the
next run takes it for the player's own), a buzzing controller and no exit summary. ExitProcess also lacked
cfg_save and the rumble stop, and it and the window's close had no shared guard, so both could run at once and
write the summary twice while the present thread still added to it.

gfx_present.c rt_exit_begin is the one guard (the first caller runs; another thread waits for good; the same
thread again returns, and svc.c ends the process then rather than resume guest code). rt_exit_steps runs, in
order: fs_save_wait (a Commit under way finishes, none is refused), rtss_limit_restore, input_rumble_hd_stop,
cfg_save (not for --seconds), the prewarm marker clear, prof_write, vk_mem_exit_log, a non-final log flush
(the new logq.c logq_flush, bounded trylocks, queue left on), vk_pipeline_save_exit, and fs_save_flush last.
perf.c ft_stop keeps the present thread's frame-time updates out of the exit's summary and waits at most 50 ms,
on the exiting thread, for one under way. The restore relies on rtss_limit.c's closed flag to stay restored
while frames go on during the pipeline save. The log order changes: "frametime: exit" and the stutter-map exit
lines now come before the pipeline save's lines.

Not reproduced before the change. tests/exit_order_check.py (new: the step order, the once-guard with a second
thread, ft_stop's wait and bound, and statically that the three exits use the helper with the RTSS restore
before the pipeline save), tests/logq_check.py (with a logq_flush case), tests/save_share_check.py and the
compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:10 -05:00
chrg6
2c59addd62 Describe the domain checks in the ipc_session_check docstring.
The review of the IPC domain change (E-12) found that tests/ipc_session_check.py still described only the session and
transfer-memory checks; the domain section was listed only in the header of ipc_session_check.c. The docstring now also
says that a domain frees closed objects and gives their ids out again, answers a closed id with a bad handle, never
closes its own id, and is kept while an object with its own handle lives. The check passes; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
66d38dc0ac Put the kern_handle_close comment in host.h back in its column.
The review of the IPC session change (E-1) found that the kern_handle_close line lost one space when kern_handle_hold
and kern_handle_copy were added above it, so its comment started one column before its continuation line and the
neighbouring declarations. The line is as it was before that change again. Whitespace only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
ef2e5e24a6 Skip a change-event slot whose handle is not stored yet when a channel change signals the list.
The review of the one-event-per-kind change (E-11) found a narrow window kept from before: change_event_list counts a
slot (atomic_fetch_add on n_change_events) before it stores the handle there. audio_channels_live on the present path
running between the two read the slot's 0 and passed it to kern_event_signal, which ends the process with "not a
writable event handle". It needs a kind's first request to meet a channel-setting change.

change_event_list now stores the handle with release order, and audio_channels_live loads each slot with acquire order
and skips a slot that still reads 0; that slot's event is signalled by the next channel change.

Not reproduced before the change in a game run. tests/audio_mixer_check.c counts a slot without storing it and changes
the channels: no signal may reach handle 0, and the listed events are signalled again. Against the previous
audio_mixer.c it fails with handle 0 signalled once. The check passes, and the compile check of audio_mixer.c (also with
-Wall -Wextra) adds no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
d8c046acc4 Destroy a movie decoder's system events and free their table entries when the decoder goes.
The review of the system-event change (Ev-1) found the table still filling. Entries in host memory now count as live
for good in the full-table scan, but their objects are freed: ov_movie2.c ov_dtor freed the Video with in_ev and out_ev
still bound, so each decoder left two dead entries that nothing reclaimed (AUDIT F-1-08 measured about two entries per
reload of a save, which E-4's verifier tied to the decoder pair), and their four kernel handles stayed open. A long
session would end with "kern_bind_system_event: table full" once no dead guest entry was left to take.

kernel.c gains kern_unbind_system_event(c, ev). On a guest thread it calls the SDK's nn::os::DestroySystemEvent, which
zeroes the state byte and closes both handles, and then removes the entry under g_se_lock (the last entry takes its
place). The destroy comes first, so a signal still in flight finds the event destroyed or its handle no longer naming it
and skips it, rather than finding no entry and binding again on a host thread. Off a guest thread, or when the SDK lacks
the function (logged once), only the entry goes. ov_dtor calls it for each bound event after finalize has joined the
decoder and convert threads, then frees the Video. net_offline.c frees its HostTask with a bound event in the same way;
that file is outside this change and is handed to the coordinator.

Not reproduced before the change in a game run. tests/system_event_check.c fills the table with live guest events and
the movie's two host-memory events, unbinds one: it leaves the table, its handles are closed and its state byte is 0,
the other keeps its entry, and a second unbind changes nothing; with the table full again and the last host entry
unbound, a new guest bind succeeds where it would have ended with "table full". The check passes in both modes,
movie_planes_check passes, and the compile check of every runtime file (host.h changed) passes, adding no warnings in
kernel.c or ov_movie2.c with -Wall -Wextra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
601a954676 Say in nx2navi.ini.example how much game memory the copy sweep reads.
The review found the copy_retire text inaccurate: it said the thread reads 16 bytes of game memory per copy every 5
seconds. The sweep (nvn_api_cmd.c retire_pass) fingerprints each new copy once and visits at most 4096 known copies per
5-second pass. The text now says so. No code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:41:46 -05:00
chrg6
b072b8a2d4 Keep one copy-sweep record per handle, so a copy recorded again over the same block is not counted twice.
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>
2026-11-13 18:41:46 -05:00
chrg6
168a80f2ed Run the copy sweep at normal priority, so a preempted sweep cannot hold the recording and submitting threads.
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>
2026-11-13 18:41:46 -05:00
chrg6
88a64573a2 Say which mixer calls run without the renderer lock in audren.h.
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>
2026-11-13 18:41:46 -05:00
chrg6
6029321eff Check the movie decoder under its lock in DecodeAsync, so a sample after Finalize cannot hold the movie frame rate cap.
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>
2026-11-13 18:41:46 -05:00
chrg6
af06160284 Leave lazily armed memory out of the camera-speed search, so a sensitivity change no longer fetches GPU buffers.
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>
2026-11-13 18:41:46 -05:00
chrg6
e9becc12f3 Free closed IPC domain objects and give their ids out again, and keep a domain alive while its objects are.
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>
2026-11-13 18:41:46 -05:00
chrg6
d3a5c3c353 Make one audio device change event per kind instead of one per request, and close the event of a lost race.
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>
2026-11-13 18:41:46 -05:00
chrg6
811024e69b Count the recording copies whose control memory the game reused, behind the new NX2NAVI_COPY_RETIRE switch.
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>
2026-11-13 18:41:46 -05:00
chrg6
0b217575d8 Keep the movie deband planes per decoder, and drop the frame instead of ending the game when they cannot be allocated.
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>
2026-11-13 18:41:46 -05:00
chrg6
0672655c96 Let a destroyed vsync event give its slot when the vsync list is full, instead of ending the process on the 17th.
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>
2026-11-13 18:41:46 -05:00
chrg6
63228e7ca8 Hold a reference on an IPC session for the length of a request, and copy the transfer memory under the kernel lock.
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>
2026-11-13 18:41:46 -05:00
chrg6
465ffe67bb Check a system event's kept handle under the kernel lock before signalling it, and keep host-memory events in a full table.
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>
2026-11-13 18:41:46 -05:00
chrg6
78bfb83b2b Ask the shipped translator itself for its texture pool length before make_launcher packages it.
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>
2026-11-13 18:41:45 -05:00
chrg6
1e1e3b757f Have the preparation's prebuild report say it counts from the preparation's start.
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>
2026-11-13 18:41:45 -05:00
chrg6
75b1e0b5d9 Say that pipeline-binaries.bin is cut back also at a malformed record, and check the cut itself.
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>
2026-11-13 18:41:45 -05:00
chrg6
ad0e934fbf Rewrap make_appimage.py's docstring paragraph on the bundled files at 120 columns.
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>
2026-11-13 18:41:45 -05:00
chrg6
d2e23ce9c9 Check in carry_pool_check that gfx_shader.c's earlier pool length equals vk.h's.
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>
2026-11-13 18:41:45 -05:00
chrg6
a47c6d69de Say in docs/architecture.md which carried translations are translated again after an update.
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>
2026-11-13 18:41:45 -05:00
chrg6
6c6e7b3974 Serve a refused carried translation once the current translator has failed the shader.
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>
2026-11-13 18:41:45 -05:00
chrg6
acfc0ac24c Make excl_mt check the exclusive monitor as it is: a compare-and-swap of the value loaded.
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>
2026-11-13 18:41:45 -05:00
chrg6
e5632aea87 Point movie_timeline_check at the switch the movie timestamp sites have had since 70c99357.
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>
2026-11-13 18:41:45 -05:00
chrg6
fd91f46bfb Bring compute_prebuild_queue_check's stand-ins up to lb_next's worker limit beside the game.
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>
2026-11-13 18:41:45 -05:00
chrg6
077c363f52 Give cache_exit_snapshot_check's stand-in pipeline state the held sizes cache_save_one now stores.
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>
2026-11-13 18:41:45 -05:00
chrg6
ae90aff58a Keep every pipeline when tex_vram_pressure is 0, as the textures read it.
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>
2026-11-13 18:41:45 -05:00
chrg6
6af16f7c34 Record a pipeline's key at its fast link also when its optimised build failed first.
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>
2026-11-13 18:41:45 -05:00
chrg6
79c41ca76a Keep the whole records of a pipeline-binaries.bin that ends inside a record, and cut a failed append back out.
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>
2026-11-13 18:41:45 -05:00
chrg6
ea5087baf6 Let workers_drain drop a queued fast-link job without reading its missing library.
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>
2026-11-13 18:41:45 -05:00
chrg6
339f391cf5 Give the pipeline preparation's prebuild report its start time.
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>
2026-11-13 18:41:45 -05:00
chrg6
b04e7c85b2 Log shaders that index the texture pool with the earlier translator's length, and a texture they cannot reach.
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>
2026-11-13 18:41:45 -05:00
chrg6
48846dc9cb Destroy the pipelines given back under video memory pressure on the autosave thread, not the completion thread.
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>
2026-11-13 18:41:45 -05:00
chrg6
2f93d2740f Measure the video memory pressure that gives back unused pipelines without the free space inside the allocator's blocks.
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>
2026-11-13 18:41:45 -05:00
chrg6
f3d8f08689 Make every packaging script refuse a translator not built from the checkout's sources, early.
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>
2026-11-13 18:41:45 -05:00
chrg6
72047d12af Stop sending a request again to a new translator child after the first one gave no answer for the read timeout.
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>
2026-11-13 18:41:45 -05:00
chrg6
46c764a434 Stop serving carried translations whose texture pool array has the earlier translator's length.
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>
2026-11-13 18:41:45 -05:00
chrg6
301445b314 Name the four checks this branch added in docs/building.md's "Checks".
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>
2026-11-13 18:41:40 -05:00
chrg6
c4140a3f01 Time the finalized pools' destroys and say which thread ran them, on the "mem: sizes" line.
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>
2026-11-13 18:41:40 -05:00