An RTSS without FramerateLimitDenominator gets a fractional limit rounded to whole frames (apply: 59.94 becomes 60),
but the line after the write named the rate the game asked for: "limits to 59.940 fps" while the profile held 60. A
play-test log would hide that RTSS limits above a 59.94 Hz monitor. When the written limit over its denominator
differs from the asked rate, the line now names both: "limits to 60/1 (60.000 fps; the game asked 59.940)". What is
written does not change.
tests/rtss_worker_check.c gains round_logged (an RTSS with the denominator: 59940/1000, logged as 59.940 fps) and
round_logged_whole (without it: 59.94 and 143.856 Hz logged as 60/1 and 144/1, a whole 60 as before). The second
failed before the change and passes after it; a compile of rtss_limit.c with -Wall passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker checked that the want was still the one it had read and then stored active = 1, two steps on two atomics.
A rtss_limit_want between them (frame generation or low latency switched on, a movie, a new limit) stored the new want
and active = 0, and the worker's store then set active back to 1: frame_step_cap_ns returned 0 for the kept-out want
until the next poll, up to 500 ms in which nothing limited the game.
The worker now stores the want RTSS holds the game to (active_for, or RTSS_UNSET for none), and rtss_limit_active is
true only while it equals the newest want, so a change of the want stops matching at once and rtss_limit_want no
longer clears anything. The frame count check against the limit keeps its test that the want has not changed.
tests/rtss_worker_check.c gains active_race: the worker polls without pause while the want switches 400000 times
between 60 fps and frame generation, and after each switch to frame generation rtss_limit_active must read 0 until
the worker has seen it. Before the change 128440 switches read 1; after it none do. The other scenarios and a compile
of rtss_limit.c with -Wall pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NX2NAVI_RTSS=0, frame generation or the driver's low latency, the game keeps RTSS out of its frame limit, but a
limit the player set in RTSS itself (its Global profile or the game's own: a frame rate limit, a frame time limit or
scanline sync) still holds the frames, on top of the game's limiter, the driver's or frame generation's. Nothing read
RTSS's settings in such a run: a first want that kept RTSS out never started its thread, and frame_step_cap_ns folded
the reasons into -1, so the log said "NX2NAVI_RTSS=0 or frame generation" for low latency too.
frame_step_cap_ns now passes the reason (rtss_decide.h RTSS_OUT_SETTING, RTSS_OUT_FRAMEGEN, RTSS_OUT_LOWLAT; the
unlimited branch too), and the kept-out line names it. A kept-out first want starts the worker as well. While RTSS is
kept out the worker never loads RTSS's hooks library and only reads: its shared memory, and once RTSS lists the game
the [Framerate] values of Profiles\<exe>.cfg, else Profiles\Global, with the system's ini reader, again only when
either file's write time changes. When one of them limits the game, the log names the file, the values and the other
limiter, once per change, and rtss_limit_state returns 4, which the frame rate row shows as a new note
(UI_MENU_FPS_RTSS_TWO, in English, Russian, Brazilian Portuguese and Simplified Chinese). A later want of a limit
(frame generation or low latency switched off) loads the hooks and hands RTSS the limit as before; NX2NAVI_RTSS=0
stays read-only for the run. Pacing does not change.
Not reproduced before the change: a stacked limit needs RTSS running in a play-test. tests/rtss_worker_check.c gains
three scenarios against the fake RTSS (a Global limit with NX2NAVI_RTSS=0, scanline sync in the game's profile under
low latency and then a limit, frame generation with no limit of RTSS's own), and tests/rtss_limit_check.c builds
rtss_decide.h alone and checks which settings hold the frames and their text. Both pass, with frame_step_check and a
compile of every runtime file; -Wall adds no warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rtss_limit_restore put the profile back and set the want to unset, but the game presents on until the process ends
(the rest of the exit runs after it). The next frame_step_cap_ns called rtss_limit_want with the limit, which replaced
the unset want, and the worker's next poll applied it again: the profile kept the game's limit after the exit, and the
next run took it for the player's own. An exit before the first want, or while RTSS was kept out, found the worker not
started, and a want after it started the worker, whose first poll applied at once. A want the worker read before the
restore was applied after it too, since the worker reads the want before it takes its lock.
rtss_limit_restore now marks the run closed before anything else: rtss_limit_want returns at once, the worker skips
its polls once it holds the lock, and rtss_limit_active and rtss_limit_state read 0, so the game's own limiter paces
the frames left.
tests/rtss_limit_check.py builds tests/rtss_worker_check.c, which compiles the real rtss_limit.c against a fake RTSS
(its registry key, hooks library, profiles and shared memory) and runs each scenario in a process of its own: a want
after the restore, the player's own limit, a want read before the restore, and an exit before the first want or while
RTSS was kept out. All five failed before the change and pass after it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the 2026-11-13 session three frames of 87, 91 and 96 ms after an area load each counted as a change of pace, as each was followed by one as long, and loads that only the texture pool saw (no area file read, so no scene line) were charged to play. Consecutive long frames are now one episode, counted once with their summed time, its line giving that time and the first frame's parts; a run of more than 8 long frames none of which stands out is still a change of pace. The texture pool's start and end of an area load mark an area load for the map, as the game's own loads do.
With trace = perf, the slow write-watch queries and freezes past the 200 lines they may log are counted and given every 60 frames, so a late area load still shows them; the session's lines had run out by frame 36,387.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the 2026-11-13 session frames of 60-96 ms came 2 to 6 frames after each area load with the render thread waiting 59-86 ms for the GPU, and no line said what the GPU was doing. With trace = perf such a frame now gets a line at the next present: the timed parts that ended in its interval, their GPU time, the longest gap between two, and the longest parts with their kind (draws, copies into VRAM mirrors, pre-passes, uploads, presents) and their time in the queue. The completion thread keeps the last parts in a ring as it reads their timestamps; nothing waits for the GPU. Measurement only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The system line took the driver's device-local figure out of the process's commit, but the driver's write-combined views of that memory came to 0.2-1.1 GiB more, and that part went to "other": in the 2026-11-13 session other system memory grew 1,283 MiB in play while the process's own commit grew 307. With a walk of the address space (mem_census) less than a minute old, the gap at that walk is now a term of its own. The NVN recording copies, malloc'd, count among the C runtime heap's known parts with those unused for 5 minutes, and the line ends with the process's handle count, which rose at every area load in that session and never fell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A recording copy is retired only when the game adds its control memory again, and in the 2026-11-13 session the live copies grew from 9,138 (0.7 MiB) to 223,753 (58.3 MiB) in 7 minutes of play, about 38,000 more at each return to an area: the one real host-memory leak of that session. Each copy now carries the minute of its last use (made, called or submitted), and counters by minute give how many copies no submission used for 5 minutes, and their bytes, in the 600-frame sizes line, with no walk of the copies under the lock the recording threads take. Measurement only: retiring such copies needs this figure first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line said "a quarter of the video memory budget" whenever the card's rule raised the bound, also when the smaller term was an eighth of the installed memory: on the RTX 5090 with 32 GB of the 2026-11-13 session the bound of 4,008 MiB came from the memory, not from the card's 7,854. The line now names the term that won, and the default's own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scratch grown past 32 MiB for a large texture went back to the usual size only when the next ASTC upload asked for one, so in the 2026-11-13 session the decoder held 224 MiB for the 70 s after the first area load and from a later load to the session's end. Each present now gives it back once no texture needed more for 5 s; nothing is made then, the next request makes the usual scratch, and the buffer is freed once the upload batches recorded so far completed, as before. The present only tries the decoder's lock, which can be held while VMA makes a block. astc_scratch_trim = 0 keeps the scratch until the next upload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report runs at the present every 3600 frames in every run (600 with trace = perf) and walks the texture entries, the pipelines and the C runtime heap's summary; the 600-frame frames of the 2026-11-13 session carried 10-17 ms nothing else accounted for, and the report's own comment said it took well under a millisecond. A line after it now gives the time of the vram lines, the holes line and the owners' lines with what follows them, so the next session shows whether the report needs to move off that thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session of 2026-11-13 ended 8 s after its window lost the focus, after 7 minutes of play, with none of the game's exit lines: no frame-time summary of its last period, no stutter map of its last minute and no growth ranking at all, as the ranking came every 10 minutes. When the window loses the focus in play, the present thread now writes the frame-time summary so far (starting a new period), the stutter map and the growth ranking at its next present, at most once a minute; formatting only, nothing asks the device. The growth ranking comes every 2 minutes of play instead of 10, the stutter map every 5 instead of 10.
A process that ends through exit() rather than plat_exit now writes the log thread's queue last, after the frame-time summary's exit handler, instead of losing it.
The launcher logged a game's end only for a nonzero code, so the session's end with code 0 left no line anywhere. It now logs every end of a game it started, and when the game's log has no line of the game's own end (the window's close, the game's ExitProcess, a timed run's end, a FATAL or exit line), it notes the end there too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether a shorter hold could take some of the released textures' memory without a take-back finding its entry gone depends on what comes back late: the log said 452 entries (72 MiB) were taken back after a minute in the 2026-11-13 session, but not whether any was large or held content only the GPU made. A second line after the reuse line now gives the take-backs and the makes again after a drop by age for each kind (intact, overwritten, made by the GPU) and own memory (under 1 MiB or more), with the megabytes taken back after a minute; the kind of a dropped entry is kept with its key for the remake.
The census of what keeps the released textures over their bound ran only above a quarter more than they may keep, which with room on the card is twice the bound, so the session's 7,150 MiB against a bound of 4,008 MiB never got one. It now also runs while they are over the bound itself, at most once a minute and 60 times a run. Log lines only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Past RELEASED_HARD (16,384) the sweep dropped the first released entry in release order that was overwritten or unsampled for 2 s, render targets included, whose drop vk_texture.c already ties to a black frame, and it walked the list from its head for every drop. At the end of the 2026-11-13 session the count sat at 16,384 for a minute while 12,811 of 15,734 released entries owned no memory. The entries now go by class: overwritten ones, then ones that come back intact and are past their usual take-back gap, then other intact ones, and only then ones whose content only the GPU made; still only unsampled ones, and the one sampled longest ago when all were sampled lately, as before. One walk takes a batch down to 256 under the bound. tex_hard_order = 0 keeps the rule of 0.10.1.
The drops past the hard count get a reason of their own, so the texture origins line and the reuse line count them apart from the sweep's drops past RELEASED_MAX, with the classes they came from. The class rules are in vk_tex_release.h and checked by tests/tex_release_check.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session of 2026-11-13 met 46 pipelines late in adult Kokiri Forest (145 draws left out, mesh pipelines usable after up to 782 ms) and ended about 2 s later: the records waited for the optimised builds, and 23 of the 46 never reached the file, so the shared pipeline list folded from it would have missed them. A pipeline is now recorded as soon as a draw can use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a slow freeze the passes wait 250 ms, doubled for each further slow one, but the first fast freeze after a hold started the doubling over, so in the seconds after a load a 4-5 ms freeze came every 250 ms (Kakariko, 2026-11-13: five in a row, 16 frames apart), and freezes of 4-10 ms pushed frames of 25-52 ms over the stutter line. The doubling now starts over only after freeze_quiet_ms (default 5000; 0: the old rule) without a slow freeze. No pass runs while the texture pool makes an area's textures, when the frames are held anyway and one freeze took 32.4 ms, nor for freeze_load_hold_ms (default 3000; 0: off) after that or after an area's load. The regions that go idle meanwhile stay queued and are frozen later; their write-watch queries go on until then. A region is now queued once instead of again every freeze_idle submissions while the passes are held.
The rules are in runtime/freeze_hold.h, checked by tests/freeze_hold_check.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With mem_census = 1 the census trigger compared the private bytes with the last census, and Windows charges the video memory to them, so every gigabyte of textures started another 130-260 ms walk on the presenting thread: 22 of the 33 walks of the 2026-11-13 session came from that trigger, while the process's own commit grew 307 MiB in play. The trigger now reads the private bytes less the write-combined memory of the last walk (and what the driver's device-local figure grew since), compares them with the highest figure seen at a census, and waits 60 s after the last walk; the 3600-frame censuses stay.
On Windows one walk of the address space now gives both the commit census and the guest regions' committed bytes, which mem_guest_census walked a second time. The census counts the driver's write-combined views apart instead of as guest memory, counts as guest only the reserved guest regions and the loaded modules, and ranks the process's own allocations wherever they lie. The frame after a walk charges its time to a new "census" cause in the frame-time summary and the stutter map instead of "unattributed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The count was reset in vk_exec_pass_begin alone, so a dispatch's record kept the counts of the pass that last had its slot of the ring; the report prints them only for passes, but the record now holds what its mark was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The barrier before the merge (vk_memory.c vk_mem_mirror_end) made earlier writes visible to the merge's shader reads only; its shader writes to the imported buffers follow writes there too, and a write after a write needs the write access in the dependency's second scope. On for every device: the barrier was there already, it only covers one access more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record's copies put the count the game's culling pass wrote into the first dispatch and, without a task stage, into the draw's instances as it was. A garbage count launched up to millions of workgroups that write nothing and instances that draw nothing, enough to keep the GPU past the driver's timeout; with the fallback now the default on AMD's driver for RDNA 1 and 2, such a count would have moved a hang there instead of removing it. A single-thread compute pass (shaders/meshfb_clamp.comp) cuts the count to the buffers' room, which changes nothing that is drawn; the report word keeps the count asked for, so record_check still logs a mesh-only draw that asked for more than the buffers hold. On for every device that uses the fallback: one dispatch and one barrier per indirect fallback draw.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every RX 6000 loss whose pass marks named a pass was in one that starts with a native mesh draw, on every version from 0.6.0 to 0.9.2 and with every other setting tried each way; RDNA 1 cards on the same driver draw through the fallback and RDNA 3 and 4 on AMD's other driver draw natively, and neither has such a report. That the native mesh path is the cause is not shown. The decision moves into vk_mesh_path.h with a test of each device kind and setting; has_mesh_shader keeps saying what the hardware has, so the recorder thread and the low-end card test do not change. On those cards no native mesh pipeline is built any more: the seed list and the boot prewarm skip them. The early tail stays off with the fallback, a combination never measured. The fatal line names the mesh path, and NX2NAVI_VK_NOOPT's default applies with mesh_fallback = 0 alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pass mark now counts its mesh draws and those that take their count from the GPU; the gap before a mark notes a group recorded by the workers, whose passes have no marks, where the report said "barriers and state only"; the fatal line names the mesh path (native, the mesh fallback, the emulation). The comment on the RX 6700 XT report in vk_exec.c called the last marked dispatch the replay's own: it is the game's (op_dispatch, cs d7566eb1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RX 6000 reports of 0.9.1 and 0.9.2 ran the completion callbacks of submissions the GPU never finished before any call returned VK_ERROR_DEVICE_LOST, although the completion thread polled the device first. Every value a queue hand-over or a host signal gives the submissions' timeline or the executed timeline is now noted before the signal (vk_sync_hw.h); the completion thread and the wait after a submit end the run when a timeline reads above that note. The first occlusion query found unwritten after its fence logs its submission, the submissions' values, both timelines' readings and the GPU breadcrumbs, and the lost device's report starts with the same line, so the next report shows whether the driver gave a value above the note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This takes back the three commits that added it (cd2f9f1, e60677e, a01910d): its runtime layer and Steam binding, the Co-op tab and what it drew over the game, the launcher's copy of Steam's library file, its settings, tests and docs. The tree is as it was before them, with the bug capture that came after kept: its note on screen no longer makes room for co-op's lines, and dbg_area, which the co-op had brought and the capture uses, stays.
The four settings of that co-op join the retired names, so a settings file that still has them logs no unknown key. A steam_api64.dll that the launcher put beside the game is left where it is: nothing loads it any more.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A chunk's lookup that needs its turn returns no image and its op is replayed in turn, but the first one was logged as "render target ... is not renderable here: left out", which read as depth and cube targets the replay could not draw. Nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vulkan has no quad or line-loop topology. Draws without indices were rewritten already; an indexed quad list went to the GPU with its indices under the triangle list topology, three at a time, joining corners of neighbouring quads, and an indexed loop lacked its closing line. Off by default and read at start; no log of Ocarina has shown such a draw so far, and the new line says when one comes. tests/index_rewrite_check.py checks the index lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A draw that finds every query slot still waiting for the completion thread goes uncounted, and the report after it said only what the other draws counted, at least 1: the game takes the object it tested for all but hidden for that frame. With the setting the report writes the larger of that and the last complete count reported into the same record. Off by default and read at start; the count of such reports is logged at the 1st, 10th, 100th and so on either way. tests/oq_hold_check.py checks the rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Through a nearest filter each of the game's shadow compares tests one texel, so shadow edges are stair steps of shadow map texels that crawl over the ground as the sun cascades follow the camera. The setting is off by default and read at start; with shadow_scale above 1 the samplers were already linear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait stays shader_compress_idle_s (30) at the roomy and normal levels; every level compresses losslessly, and a
shorter wait only means a shader read again soon after is decoded once more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A thread samples every two seconds the video memory budget against this process's use (vk_mem_vram) and the system's
commit headroom and free physical memory (plat_commit_info, plat_mem_info), and turns each side into a level: roomy,
normal, tight or critical, with five points of hysteresis upward and a 256 MiB floor. Changes are logged, and the
memory report's system line ends with both levels. MEM_BUDGET_TEST_VRAM_MB and MEM_BUDGET_TEST_RAM_MB pretend a side
is that small, so that what gives memory back can be tried on a large machine; the texture cache's rules are unchanged.
tests/mem_budget_check.py checks the levels and the hysteresis.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shader memory cache held 777 MiB of SPIR-V for 7115 shaders for the whole session. A background thread now
compresses the SPIR-V of each shader no reader pinned for shader_compress_idle_s seconds (zstd level 1, 26% of the
bytes on the test install's 44,151 modules) and frees the plain copy; readers pin it through gfx_shader_spirv_pin and
get it decoded back. What the drawing thread compares at pipeline misses and draws, the module's FNV-1a and its Binding
decorations, is kept with the shader, so module_binding_missing, the water corrections and the DLSS, crumbs and skip
lists decode nothing. A compute pipeline created at a dispatch and a mesh shader's first pipeline key still decode on
that thread, and the memory report counts those decodes apart, with the SPIR-V as held against uncompressed and the
shaders held compressed. shader_compress = 0 keeps it plain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Readers pin the store and get the plain words, decoded from the frame when the plain copy is gone; one packer makes the
frame of a store unused for a while, checks it by decoding it, and frees the plain copy. The lock is held for a few
loads and stores only, never across a decode, an encode or a free. tests/spv_store_check.py walks a store through its
states and runs four reader threads against a packer for three seconds, comparing every word they read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
floor_watch.c takes up to twelve of the floor scan's survivors (the platform's rigid body controller unit first, then one per object at the pivot's height), watches them four at a time through the debug registers on every thread (platform.h plat_watch_set, Windows only), counts each access's call chain in a lock-free table and logs the chains with the executable's symbol names after each pass. prof_syms gains a callback form for that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The character controller's three follow-surface modules kept only a tenth of the floor's velocity whenever the physics' inverse step passed 100, which every update above 100 fps did. The gate now compares with 100 over frames per update from the patch page above 60 fps, and with the game's 100 at 60 and 30 fps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every report adds a line for video memory outside VMA (the driver's usage and budget per heap against VMA's blocks, and counts of pipelines by kind and by last use, stage libraries, compute pipelines, layouts and descriptor pools, with the swapchain, DLSS and FSR sizes), a line that splits the process's commit by owner (the game's heap and maps, device-local memory, VMA's host blocks, the C runtime heap with the shader caches, pipeline tables, texture cache, log queue and flight recorder in it, the driver's pipeline cache data and the rest), and a line naming what grew by more than 64 MiB since the report before and since play began. Every ten minutes of play and at exit a ranking line orders the categories by growth and marks those that only climbed; tests/mem_growth_check.py checks that bookkeeping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Link is carried by the Deku Tree's floating platform fully at 60 fps, about a tenth at 160 and not at all at 240,
which follows the solver steps an update (4, 2, 1 with physics_steps = 1) rather than those a second. The new set
osolverfull raises the rounded-up count to the 60 Hz frame's 4 in every update; physics_steps = 2 switches it on and
osolver off at start, and the mode is logged. 60 and 30 fps keep the game's 4 and 8. It costs more physics time above
60 fps: 576 solver steps a second at 144 fps and 960 at 240, against 288 and 240 with 1, the default, which stays
until a run confirms the carry. frame_step_check runs the lifted words of all three counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Released textures go once they have gone unused for the hold, and what a switched-off upscaler or SMAA held is freed, with no setting to keep them. A vram_use line left in a settings file is known and read by nothing. Memory use is to be decided by the card's and the system's budget instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F8 now always writes a folder under logs/captures: the area, Link's position and rotation, the camera of the frame on screen and the game's own, the presented frame as a PNG, frame times around the press, the machine, the settings that differ from the defaults, the end of the log, the save, and the game's flags and texts. A note on screen names the capture. The flight recorder's F8 dump goes into the capture's folder.
NX2NAVI_CAPTURE_REPLAY starts a run on a capture's settings, teleports to its area, walks Link to where he stood and puts the free camera where the camera was; tools/capture_run.py prepares the profile from the capture's save, runs it and compares the two captures. docs/bug-capture.md has the files and the steps.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>