Commit Graph

931 Commits

Author SHA1 Message Date
chrg6
2ede7101fa Build game 1.1.1 with the kit's execution profile, and make its instrumented build.
tools/make_game.py, the build players run through the launcher, read no profile: only tools/build_game.py did, which
does not build 1.1.1. A kit with data/pgo-v111.profdata (build/pgo/ in a checkout) now compiles the lifted code at -O2
with it and the runtime too, the prebuilt runtime library included. The profile is left out, and every command and
key is what it was, when the kit has none, when its record names another recomp/ than the kit's, when a compiler of
the build refuses the file in one trial compile before the others start, and with NX2NAVI_PGO=off. The record holds
recomp/ by content, which a kit without git can compare. The object keys hold the profile's content and not the kit's
folder, so a launcher update that moves the kit keeps the objects.

--pgo gen (NX2NAVI_PGO=gen from a launcher) builds the instrumented game into the game folder: it counts into
<out>/pgo, records what it was built from for the merge, and stops at once when the build tools lack the profile
runtime. tools/pgo.py merge takes that record, refuses a game built from another recomp/ and finds llvm-profdata in an
MSYS2 folder too. tools/check_content.py refuses profiles in a commit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:30:46 -05:00
chrg6
a84a555128 Describe the low latency mode in the architecture document.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:29:59 -05:00
chrg6
191a439fe4 Add a Low latency row to the menu's Screen tab.
Off, On, and On with boost where the mode is NVIDIA's, in English, Russian, Brazilian Portuguese and Simplified
Chinese. The row is greyed out with the reason where the mode cannot run: a driver with neither extension, a run in
which DLSS frame generation brings its own low latency, and NVIDIA's while a frame generation presents the frames. A
mode the driver refused says so under the value.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:29:59 -05:00
chrg6
c37fb7dc40 Hold each frame's start in the driver while low latency is on, and let that keep the frame limit.
The driver's sleep sits where the game's frame starts: at its first pad read or window image acquire after a present,
whichever comes first, before the input is sampled. The frame's number, the count of the game's presents, goes with
its work: the guest thread marks the simulation and the first submission, the replaying thread tags its queue
submissions and marks their end, and the present thread marks the host present, which carries the number too. The
window's swapchain is made with the latency mode while NVIDIA's path is on, and remade when the setting changes.

While the mode is in effect the pacer holds nothing, so a picture goes out as soon as it is drawn and the limit is
kept by the driver before the pad is read; RivaTuner Statistics Server is kept out as with frame generation. The pacer
takes the limit back while a movie plays, while no host present goes out, and for the rest of the run when the frames
show the driver does not hold it. With the mode off every hook returns at its first check.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:29:59 -05:00
chrg6
2f30f1a2e5 Add the low latency setting and the module that drives the drivers' own modes.
NX2NAVI_LOW_LATENCY (0 off, 1 on, 2 on with boost; live) is for low latency without frame generation: NVIDIA's mode
through VK_NV_low_latency2 and AMD's through VK_AMD_anti_lag, each enabled on the device only where the driver lists
it, NVIDIA's with VK_KHR_present_id, which it needs, and AMD's with its feature. A device made by the DLSS Frame
Generation proxies gets neither: that path keeps its own low latency. vk_lowlat.c holds the calls, the frame numbering
and the statistics line; nothing calls into it yet beyond the start and the end of the device.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:29:59 -05:00
chrg6
d9bc002d9a Play the game's own amplitude at the default vibration strength on the HD rumble path.
The default strength is 130 to make up for what two motors lose. A linear actuator that takes the game's value needs none of that, and 1.3 times the amplitude clipped the strongest pulses at the safe maximum. The HD path now divides by the default, so the setting scales from the game's own amplitude.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:29:04 -05:00
chrg6
aeb6c0d5c1 Add the HD vibration row to the Controls tab and describe HD rumble in the controls guide.
The row switches rumble_hd and is greyed out, with a note of what it needs, while the gamepad in use is not a Switch
Pro Controller or Joy-Con. Its texts are in all four menu languages.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:28:36 -05:00
chrg6
df8431da7b Send the game's vibration values as they are to a Switch Pro Controller or Joy-Con, behind rumble_hd.
With NX2NAVI_RUMBLE_HD=1 (live, off by default) the gamepad the game reads, when it is a Switch Pro Controller, a
single Joy-Con or a pair of them, gets each side's low and high band with its own frequency and amplitude in the
controller's rumble report, and no dual-motor call any more. A Pro Controller and a single Joy-Con take the report
through SDL's effect call, over USB and Bluetooth; SDL refuses that call for a combined pair, so a pair's reports are
written to its two HID devices directly. Every other gamepad, the Switch 2 controllers included, keeps the motors.

The game's thread posts its newest values and a thread of input.c writes the reports: at most one every 15 ms
(rumble_hd_ms), a held value again every 30 ms, and the neutral value when the game stops sending for 250 ms, when
another gamepad takes over, when the setting is turned off and before the process ends. The rumble strength scales the
amplitude and rumble = 0 silences this path as well. With the setting off nothing is written to the controller: the
log line at the take names it by its ids, and the first report is the trial.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:28:36 -05:00
chrg6
59e3c6c35a Add the encoding and the report schedule for HD rumble on Switch controllers, with their check.
runtime/rumble_hd.c turns one of the game's vibration values (a low and a high band, each with a frequency and an
amplitude) into the four bytes a linear actuator's rumble report takes, clamps the frequencies to each band's ends and
the amplitude to the last safe step, and decides when a report is written: the newest value only, a held one again
every 30 ms, the neutral value three times once the game stops. Nothing calls it yet.

tests/rumble_hd_check.py builds it alone, checks known values, the clamps and a batch reduced to its reports, and
decodes a sweep of values with formulae of its own.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:28:36 -05:00
chrg6
a79b0a5125 Remove the counters of what a moved texture slot generation costs the draws' kept bindings.
Two play sessions answered the question they were added for: about 0.3 whole-draw skips a frame are lost to the generation moving alone, too little to build the per-slot check for.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:06:15 -05:00
chrg6
85089408f0 Merge main's replay workers default and menu row into the 0.10.0 line.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 22:06:04 -05:00
chrg6
264dbe42cc Merge the stability and memory pass into the 0.10.0 line.
Conflicts: the settings key lists and section maps take both sides' entries.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 21:49:17 -05:00
chrg6
c4268779fc Write a V register with one 16-byte store.
A scalar result (an FP operation, a scalar load, FMOV, a compare, a conversion, a 64-bit vector operation) was assigned
to the register as a zero-extended 128-bit integer, which the compiler writes as two 8-byte stores, and INS and a
single-lane load wrote their lane alone. The next 16-byte load of the register then cannot take its value from the
store buffer and waits until the stores have left it.

Every such write now builds the whole value in a vector register and stores it once (runtime/simd.h): VSET puts a
scalar in the low bits with zero above it, VSET128 writes a 128-bit value built in an integer, and VINS loads the
register, replaces one lane and stores all of it. VINS passes the value through an empty asm statement, without which
the compiler turns the load, the insert and the store back into a store of the lane.

In the 360 guest functions a play profile sampled, stores of a part of a V register fall from 64322 to 4 and whole
stores rise from 10932 to 43665; the four left copy a 16-byte guest load that the compiler itself splits because the
memory was just written in halves. Host instructions fall from 1448075 to 1427163 and stores to the Cpu from 177160 to
145566; its loads rise from 141090 to 143521, as an insert loads the register it changes.

The lifted text of FP and SIMD code changes with this, so those files are compiled again.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 21:33:21 -05:00
chrg6
1f5ee67f7e Give every skinned character motion vectors of its own, not the player alone.
A scene draw whose vertex shader reads a matrix palette and whose vertex layout feeds its joint inputs unsigned integers is drawn a second time into the motion vectors with last frame's blocks and camera, as the player's meshes are, with the same depth test and the same guard for bones that turn sharply. Which draw of last frame it continues is decided when the vectors are made, so the replay's threads cannot change it; a draw in doubt keeps the camera's motion. The player's own draws stay with their ownership records.

skin_motion (NX2NAVI_SKIN_MOTION) is 1 by default and 0 turns it off. The motion status line now counts the skinned draws seen, those drawn with their own motion and the others by reason. It has compiled and passed its checks; it has not been played.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:03:59 -05:00
chrg6
6e9d9fca5f Add the rule that pairs a skinned draw with last frame's draw of the same mesh, and its check.
A draw is known by its mesh and program and by its number among the frame's draws with that key, counted in the order of the command stream. It is paired with none when the key is new, was drawn another number of times, has a palette of another size, or has its palette at neither of the two addresses that place last had.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:03:45 -05:00
chrg6
0b0920c54a Find the matrix palette a skinned vertex shader reads, from its translation.
A vertex shader's reflection now names the uniform block its skinning indexes with a joint index times the size of a bone matrix, and the inputs the joint indices come from. Over the vertex shaders of a played folder's recorded pipelines it finds a palette in 253 of the 254 drawn with an unsigned integer attribute and in none of the others; the palette is at c6 or at c7, which the list of banks a shader reads does not tell.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:03:45 -05:00
chrg6
cd0b1a387d Do the whole warm on the boot screen at the first start after a driver change.
A start that finds the pipeline cache written by another device or driver has nothing built, and everything was
compiled beside the game: an 8-core integrated GPU drew fewer than 600 frames in its first 11 minutes. Such a start
now waits on the "Preparing shaders" screen, with counts, for every recorded pipeline, for the compute pipelines on
every core at normal priority, and, when a sample says the driver's own cache lacks the prepared stage libraries, for
all of them on every core. The memory gate still holds for the builds, and the library builds write the preparation's
journal while the start waits, so a variant the driver's compiler dies on is skipped from the next start on.

boot-warm-owed.txt beside the caches names the GPU and driver: a start closed during the warm is followed by one that
goes on with it, three at most, and "done" keeps later starts ordinary while the cache file still carries the earlier
driver's header. When the boot stops waiting (prewarm_wait_ms, or a minute without progress) the builders step down
to the background's thread counts and idle priority. prewarm_full = 0 turns the warm off.

With nothing in the driver's cache the probe took 425 s on an RTX 5090 with 16 CPU threads: 87 s for 5,775 recorded
pipelines, 19 s for 4,075 compute pipelines and 317 s for 42,231 prepared variants.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 20:03:12 -05:00
chrg6
97c3bac3bd Keep a small buffer clear and the counter reports of the same record in the order of the stream.
A ClearBuffer or ReportValue is a fill the GPU makes where it stands in the stream, and guest memory has its bytes once the GPU got there. A counter report reaches guest memory as a CPU write after its submission's fence, and a render condition reads the record when it is replayed. So a report followed by a clear of the same record ended as the report (in one submission always, across two when the completion thread was behind the GPU), and a condition replayed after the clear read what memory held before the fill, or the 0 of the report the clear had overwritten.

A clear of at most 256 bytes into CPU-visible memory is now also applied at its place in the replay to the records it covers that a counter report went into: the covered bytes get the value in guest memory at once, reports replayed before the clear leave those bytes alone when their write comes, a condition reads them from memory, and a report replayed after the clear writes the whole record as before. The GPU's fill is recorded as before. Other small clears stay the GPU's fill alone: the GPU reads guest memory too, at once in an imported pool and through the next upload of the written page in a mirrored one, so a write ahead of it into a draw count or an atomic counter would reach passes that still want the old value. NX2NAVI_CLEAR_SMALL_CPU=0 turns it off.

Whether the game clears counter records is not known. The pass timing captures of 75 scripted runs show one buffer fill a frame and nothing else about it, and the frame dump does not list buffer fills. The periodic vk_usnap line now counts the small clears, the last one's size, value and address, how many covered a record with a report, the reports into bytes cleared before, and the report writes that left cleared bytes alone.

Checked with a build of the dev exe, the launcher tests, the device address table check, and a scratch link of vk_exec_xfer.c against stubs that replays reports and clears in both orders. Not run in the game.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:49:40 -05:00
chrg6
433cc62cce Leave the shared DLSS feature off until it has run on a card.
It was compiled and linked only, and in this game the dynamic resolution's steps may never change the scene's render size, so it has nothing to gain by default yet. dlss_range = 1 turns it on.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:43:57 -05:00
chrg6
8273d881a4 Run the smaller render sizes of a DLSS mode on that mode's feature, with the history kept.
Each render size had a DLSS feature of its own, so a step of dynamic resolution or a screen at another share cost a
feature creation and a new history. The feature created at the render size of the DLSS mode set now also takes every
smaller render size that DLSS reports for its output size and quality value (the optimal settings callback's dynamic
minimum), as the sub-rectangle of that frame's own images. A size above the feature's own, below the minimum, of
another shape or output size, or met under another mode still gets its own feature, and a refused evaluation turns the
sharing off for the run. The first frame of another size is still left out of the per-object motion pass and still
starts frame generation anew.

The setting is dlss_range (NX2NAVI_DLSS_RANGE), 1 by default: a run whose render size never changes creates and
evaluates its feature with the parameters it had, and adds one query after the creation. The log says for each feature
which render sizes it takes, and the status line counts the changes of size that kept the history against those that
made a feature. Compiled and linked only; no evaluation of a shared feature has run on a GPU.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:43:49 -05:00
chrg6
20ab8d0d2d Count what a move of the texture slot generation alone costs the draws' kept bindings.
Registrations no longer move the generation while a submission is replayed, but the replay's own lookups still do when they take a released texture back or make one, and so does a texture another thread of the game finalizes meanwhile. Every such move drops the whole-draw skip, the shader memo and the kept descriptors of a draw, buffer descriptors included, whether or not the slots the draw reads changed.

A new line in the texture pool's periodic log says, per frame, how many graphics binds found the generation moved within their command buffer, how many whole-draw skips and shader memo hits it alone cost, and how many kept buffer and texture descriptors were resolved again from the values they were kept under. One play session then shows whether checking only the slots a draw reads would spare anything. The counts are kept per recording context, since the replay workers record at once.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:45:12 -05:00
chrg6
016c90fafb Wait at the start for every recorded pipeline when the pipeline cache was written by another device or driver.
After a driver update pipeline-cache.bin is dropped and every recorded pipeline is built from nothing. With the pipeline library the boot waited 20 s and left the rest to be built beside the game. In a player's log of an 8-core integrated Radeon that was 2,141 of 8,122 pipelines, finished 296 s after the start, with the 42,231 prepared variants being built behind them: the game drew fewer than 600 frames in its first 11 minutes, which reads as a freeze. A start that finds such a file now waits for all recorded pipelines, with their count on the boot screen, as a start without the library does; NX2NAVI_PREWARM_FULL=0 keeps the 20 s. The prepared variants still follow in the background. Checked with --probe-prewarm on a cache copy whose header names another device: the line is logged and all 5,775 pipelines are built before the stage ends.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:31:56 -05:00
chrg6
bc256496cb Build task and mesh shader pipelines without the driver's optimiser on AMD's driver for RDNA 1 and 2, unless vk_noopt says otherwise.
That driver loses the device in play ("No fault detected", a timeout) on RX 6600 to RX 6950 XT cards in every version from 0.6.0 to 0.9.2, with the pipeline library on or off and the lazy write-back on or off (#591, #602, #659, #683, #684, #689, #635, #719, #279, and fourteen of twenty sessions in a player's launcher log of an RX 6800). Where the pass marks name a pass the GPU was in, twelve logs of five reporters, its first draw is a mesh pipeline: mesh shader ba4302f1 with fragment shader f64a96dd in nine, the one-draw pass of 1bbdb17e with 6cc3a476 in three. The five logs of 0.9.1 and 0.9.2 stop between the compute dispatch before such a pass and the pass. vk_noopt = mesh was there as a test switch for this suspicion and no reporter had run with it.

It is now what an unset vk_noopt means on that driver branch; every other driver keeps 0, and vk_noopt = 0 gives the optimised pipelines back. The picture is the same; the mesh and task shaders run slower, by how much is not measured, and those pipelines are built once more because the flag is part of the driver's cache key. That the optimiser is the cause is not shown: no AMD card ran this.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:31:56 -05:00
chrg6
07557fe06c Give an empty slot's handle for a texture id out of range instead of ending the game.
nvnDeviceGetTextureHandle ended the game when the texture id was negative or past the table. The game passes -1 when it has no texture to give, as when its render-target heap is full; the handle of the last slot, which no game registers, draws the stand-in like any slot without a texture. Logged up to eight times.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:31:25 -05:00
chrg6
724a9d98ce Size the game's render-target heap for the scene's share of the picture, so a scene at the picture's full size fits it.
The heap the engine's render targets live in is made for a 2560x1440 picture whose scene is three quarters of it at most. It grew only with the picture. With DLAA, or FSR at native size, the scene is the whole picture: at the game's own 1440 lines that is 2560x1440 targets where the game never passes 1920x1080, in a heap that had not grown at all. The render-target manager's search for a free block then takes its out-of-memory path and reads through a null block: a player's game ended there (main+0x3ed00, reading address 0x48) each time a dialogue closed in Castle Town on 0.8.0 with dlss = dlaa (#605), the same function that failed at 5120x2160 before the heap followed the picture.

The heap and the application pool now grow by the scene's pixels over the game's most as well: 238 MiB at 1440 lines with the upscaler setting on, nothing without it, and nothing at 1080 lines, where a full scene is the game's own size. The share is decided at the start, from the settings; an upscaler first switched on in the menu of a run that began without one keeps the scene at three quarters until a restart, and the log says so. Compiled; not played.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:31:25 -05:00
chrg6
e3e4b2d650 Log what keeps the released textures above their size bound.
The bound on the released textures' memory only takes entries that are old enough and that no lookup reaches. The texture pool stamps every GPU-owned entry that a slot's last fill resolved to in each submission, a finalized slot's released entry included, so such an entry never counts as idle: in a session at 5120x2160 the released entries held 9.2 and then 10.1 GiB against a bound of 4,008 MiB, and its overwritten render targets were logged as "last used 1 presents ago". Nothing said which rule held how much. When the released entries stay a quarter over the bound after the drop, one line now splits them: released too lately, sampled lately, shown by a pool slot and not sampled (and of those, the ones another target was drawn over), reached by another lookup, next to go. No rule changes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:21:43 -05:00
chrg6
e1b1691e28 Make a refused upload submission again once the batches in flight have finished and the kept buffers are given back.
vkQueueSubmit of an upload batch that the driver refused for memory ended the game at once ("upload submit failed (-2)": five logs of three reports, all on integrated GPUs, whose device heap is the machine's memory and fills before any commit check sees it). A refused submit leaves the command buffer and its fence as they were, so the batch is now submitted a second time after the batches in flight are waited for, the objects that waited for them are destroyed and the staging cache, the arena's free chunks, the snapshot pool and the spare blocks go back to the driver, as a refused staging buffer already does. A second refusal ends the run as before. Compiled; the refusal was not reproduced here.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:21:06 -05:00
chrg6
18aa16f2df Skip the menu's draw for a frame when its vertex or index buffer cannot be allocated, instead of copying through a null pointer.
The menu's Vulkan back end went on after a failed buffer allocation: it bound and mapped memory it did not have and copied the frame's vertices to the address the failed mapping left, null. Two reports of 0.8.0 end that way right after the settings menu opens with video memory used up, one on an integrated AMD GPU and one on an RTX 2050: two "got VkResult -2" lines, then an access violation in the C runtime's copy, writing address 0x20, not on a guest thread. The allocation now leaves no buffer and size 0 when it fails, the draw returns before anything is recorded, and the next frame asks for the buffer again. The change is marked in the vendored file and named in THIRD_PARTY_NOTICES.md. Compiled; the failing allocation was not reproduced here.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:19:59 -05:00
chrg6
222a025c52 Give back what a host thread keeps for itself when it ends.
Every guest thread is a host thread, and the game makes and ends them all through play. A thread that slept through plat_wait_ns kept a waitable timer, one that read or wrote a file an event for each, and one that decoded zstd through the host a decoder context of about 100 KiB; nothing closed or freed them when the thread ended, and a detached thread's own record stayed allocated. They are now given back when the thread's function returns: the handles in win32.c thread_main, the decoder context through plat_thread_end_hook, and the record by whichever of the thread and its detach comes last.

tests/thread_end_check.py runs 416 such threads and compares the process's handle count before and after. tests/sync_wake_check.c gets the two stand-ins kernel.c has needed since the service wait log, so that check builds and passes again. The Linux file only gains the hook call and was not compiled here.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:18:25 -05:00
chrg6
641c866e8a Catch up after a stall of the audio renderer by rendering the owed frames without queueing them.
A frame that was not rendered when it was due stayed lost: the timer set its
deadline to now once it was more than 100 ms late, and the device-paced path
only filled the ring to its target again, so after every gap the sound was
heard that much later than the picture it belongs to.

Frames that are owed are now rendered and dropped: mixed in full, so the
voices, the effects and the sinks the game reads back advance, and not queued
for the device, whose queue is filled to the same target afterwards. While the
device paces, what is owed is the silence it played for want of frames,
counted in its own samples from an underrun until the prefill is queued again;
the wait for the first prefill and anything owed across a stop do not count.
On the timer a frame is owed when its deadline passed a whole frame ago. A gap
of more than 3 seconds is not caught up: the sound goes on from now.

NX2NAVI_AUDIO_CATCHUP=0 turns it off and keeps the old timing. The audio
statistics line counts the catch-up frames and the gaps that were too long,
and tests/audio_mixer_check.py checks the device's side of the accounting.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:17:48 -05:00
chrg6
42d16e7f0b Ramp a changed volume across the audio frame instead of applying it at once.
The mixer applied each voice's mix volume and each splitter destination's
volume to a whole 5 ms frame as one constant, so the level jumped at the
frame's first sample whenever the game moved a volume, which is heard as a
click. It now keeps what each voice and each splitter destination was mixed
with in the frame before and goes from there to the new value in equal steps
across the frame's samples, as the console's renderer does. An unchanged
volume stays on the constant path.

A voice that has just started, one that was silent in the frame before or was
moved to another mix or splitter, a destination that is new or was moved, and
the first frame after the renderer starts take the new volume at once.

NX2NAVI_AUDIO_RAMP=0 turns the ramps off. The audio statistics line counts the
render passes that ramped a volume. tests/audio_mixer_check.py builds the
mixer with the rest of the runtime stubbed and checks the ramps and the cases
that must not ramp.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:17:48 -05:00
chrg6
202fd47ceb Let the flight recorder dump on its own when one frame flashes.
With flight = 1 and its thumbnails kept, the recorder now also writes a dump when a frame's picture differs strongly from the frame before and the frame after while those two agree, which is what a one-frame flicker looks like. Each thumbnail is reduced to 16x9 cells of mean brightness where the present thread already sees it complete, and a frame is a flash when at least three cells are more than 24 of 255 brighter or darker than in both neighbours, the neighbours being alike in those cells, around them and over the picture as a whole. A camera cut, a fade, a pan and shapes crossing the picture are no flash, and a frame beside one without a thumbnail is not judged. The dump is the one F8 writes, named "flash" with the frame's number in its header line, at most one every 30 seconds. flight_flash = 0 turns it off; flight_flash_diff and flight_flash_cells set the two thresholds.

On the thumbnails of earlier dumps the defaults flag the frames whose scene was white once and nothing in the runs without that fault. tests/flight_flash_check.py checks the test itself on drawn pictures.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:16:30 -05:00
chrg6
95dc476ee7 Scale the scene with the edge-adaptive filter where the game's own upscaler ran.
The stand-in for the game's upscaler enlarged the scene with a linear filter, which is softer than the console's picture. It now enlarges with the edge-adaptive filter the present already scales to the window with: the same shader, built a second time with a constant set so it squares what it filtered, as the linear pass does. The stored square-root values are filtered as before and the decode is the same. standin_filter = bilinear brings the linear pass back, and the setting is live. The linear pass also stays where nothing is enlarged and while DLSS or FSR 3.1 runs, since that upscaler scales the scene itself and its picture goes over the stand-in's. The movies do not go through this pass and are not changed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:16:18 -05:00
chrg6
823e0cdcd4 Raise the pipeline cache's size limit to 1024 MB, and leave never-drawn list pipelines to their first draw on a machine short of memory.
The limit was 512 MB, and a file over it starts the run empty until a play file replaces it. With the pipeline list of 0.9.1 one run builds more than that: on an RTX 2080 Ti (#722) pipeline-cache.bin held 551 MB and the play file the run then wrote 541 MB, so both were dropped at every start, 7,247 of 8,531 pipelines were built again each time (17 s of the boot) and 541 MB were written in every session. Seven of the players' logs read here have a file over 512 MB, five of them between 517 and 691 MB. At 1024 MB those are read, the play file is folded in once and the loop ends; a start that still finds both files over the limit now says so.

pipe_seed_boot (auto, 1, 0) decides whether the boot builds the pipelines of the shared list that the install has never drawn. Each is a pipeline the driver holds for the whole run: the boot's pipeline stage went from about 1.6 GB of commit on 0.8.0 to 3.9-4.4 GB on NVIDIA cards with the list (1.5-1.8 GB on AMD's driver), and 0.3-0.6 GiB of video memory. auto, the default, builds them as before except on a machine that starts with less commit free than the game takes in play, the start's own memory warning: there they are made at their first draw from the pipeline cache and recorded as the install's own. Mesh pipelines are built at boot either way. Compiled; not run in the game.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:13:40 -05:00
chrg6
495d118e9e Add an experiment that turns the game's occlusion queries off, off by default.
The new patch set occlusion_off makes six reads of two parameter bytes read 0: CreateZPrePassOcclusionQuery and CreateSamplesPassedQuery, where the game first writes their options and where an update compares and stores them. The game is then left without its occlusion queries, their reports and the conditional renders that depend on them, and draws what it would have tested: more draws, and no query results to wait for.

patch_occlusion_off = 1 turns it on at the next start. The key has a row in the settings table, which gives the set its default, a section and an entry in nx2navi.ini.example. What it costs and saves in play has not been measured.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:13:30 -05:00
chrg6
2818857d1b Show the middle 16:9 of a wider picture in the picture a save file keeps.
The game draws the whole picture into the 543x306 target of the save's picture, so a picture wider than 16:9 came out squeezed. The new set wide_save has one site in the viewport helper, where it loads the address of SetViewport: a viewport of exactly 0, 0, 543, 306 gets the patch page's two numbers added to its x and its width, without branches or flags. widescreen.c writes them for a picture wider than 16:9 and switches the set on only then; the scissor, set before, keeps the target's size.

C generated before the set existed keeps the other widescreen sets, and the log says that the save's picture is squeezed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:13:19 -05:00
chrg6
6d049c244f Keep the water's ripples moving above 60 fps by carrying the step's fraction for each water object.
The water renderer runs trunc(step) times the water's substeps of its ripple simulation a frame, which is none while the step is under one frame: the rings stood still above 60 fps. A new site of the set ostep hands the cut to frame_rate_water_host, which adds the step to a remainder kept for the water object and returns the whole steps in it. The waters have a table of 64 slots of their own, as fewer slots than surfaces would have them take each other's in turn.

frame_step_check.py calls the water and day hooks as their sites do. It also gets the three platform stubs it has lacked since the day clock's hook, without which it did not link.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:13:19 -05:00
chrg6
cc0b467a2c Gather the log lines of a quarter of a second and write them at once.
The log thread wrote as soon as it woke, which outside a burst is once for every line: each line woke it and was a write of its own to the log file and the console. It now waits a quarter of a second from the first line it finds, or until the queue is half full, and writes what was queued by then at once, so the log is up to that much behind the game. What is being gathered stays in the queue, so a fatal error, a crash report and an exit write it out before their own lines as before; a game ended from outside loses those last lines. The queue's test checks the gathering, the half-full write and that a process which ends at once, by an exit or as a fatal error does, leaves every line in its file.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:12:51 -05:00
chrg6
1c975316a3 Skip the index-buffer bind of a draw that repeats the one before.
Every indexed draw bound its index buffer. The command buffer's kept state now holds the last bound buffer, offset and index type, and a draw with the same three records no bind. They are cleared with the rest of that state wherever the replay continues in another command buffer or something else has bound into this one, as the kept vertex streams are, and the indices the replay makes itself for quads, line loops and byte indices go through the same bind.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:12:51 -05:00
chrg6
91dd6de969 Make a draw whose shaders write memory wait for its pipeline like a one-shot draw.
A draw into targets the game renders every frame is left out while its shaders or pipeline build, because the next frame draws it again. A storage buffer the shaders write or an image they store into is not redrawn that way: with the draw left out, that memory keeps what it held for everything that reads it. Such a draw now takes the wait a draw into one-shot targets takes, from the frame's wait pool, and is skipped only at the end of it. Only the stages already translated can say that they write; a draw whose writing stage is still being translated is skipped as before. draw_writers_wait = 0 turns the rule off, and the periodic line about waited draws counts the draws that waited by it alone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:12:51 -05:00
chrg6
afe44e0e54 Let the device address table read a new VRAM mirror only once the mirror's first copy has run.
A mirror is made while a submission is recorded, and its first copy of the pool is in that submission's prologue. The table's pages moved to the mirror at once, and the table is memory the shaders read as they run: every part already handed to the queue read the mirror through it before the copy had run, memory nothing had written yet and, on a card over its budget, a block the driver had not made resident for those parts. A GTX 1650 4 GB lost its device that way at area loads in every session of 0.7.1 (#507): six reports of a GPU read fault at byte 0 of a mirror made in the faulting submission, each the first byte of a new 2 MiB-aligned block, with mirrors being made over the budget, and none with the table kept empty. The mirror code is the same in 0.9.2.

Now the pages keep reading the imported buffer and take the mirror as their twin, so what shaders write through the table goes to both and what they read is guest memory, as before the mirror existed. Once the GPU has finished the submission that holds the copy, the pages move to the mirror with the imported buffer as the twin. gpa_late = 0 moves them at once, as before. tests/gpa_check.py covers the new table step; the mirror side was compiled and run through --probe-vkmem, not played.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 15:09:42 -05:00
Vasilis Manetas
eaf10677fa Import guest memory as dma-bufs through udmabuf on Linux instead of keeping shadow copies of it.
On Linux every CPU-visible pool had a host-visible shadow copy, because amdgpu treats a host-pointer import as a userptr object that it looks up again at every submission and invalidates whenever the write watch or a freeze re-protects its pages. Now the watched reservations are shared mappings of one memfd, /dev/udmabuf makes dma-bufs of page ranges of it, and the device imports them (VK_KHR_external_memory_fd, VK_EXT_external_memory_dma_buf). A dma-buf pins its pages once and is not tied to the process's page tables, so protection changes leave the GPU's mapping alone, and nothing is copied.

RADV puts every imported memory object into every submission, so pools share their imports: guest addresses are cut into 64 MiB spans (NX2NAVI_DMABUF_MB), and each pool is a buffer bound into its span's memory at its offset. A new span is cut back at the spans already there, so spans overlap only where a pool itself lies across an older span's edge, since amdgpu counts every span's pages in its GTT use. A decommit punches the pages out of the memfd, or zeroes them in place where a dma-buf pins them, and the pages a freed span pinned that nothing committed go back to the system.

It is on by default where /dev/udmabuf opens, udmabuf's size_limit_mb is 1024 or more, the device has both extensions and NVIDIA's kernel module is not loaded. A start-up probe imports one page as the pools will be; any refusal keeps the shadow copies, and the log says why. NX2NAVI_DMABUF=0 keeps the copies. tests/platform_dmabuf_test.c checks the shared mapping without a GPU, and docs/building.md ("Memory") describes the imports and what they need.

RX 6800 XT (RADV, Mesa 26.2.6), one build, interleaved pairs with NX2NAVI_DMABUF=1 and 0: Kokiri Forest 60.7 against 40.3 fps (3 pairs, +51%, standard error 0.4), 1% low 50.9 against 34.0, frames over 25 ms 5 against 768; Hyrule Field 55.7 against 42.4 fps (2 pairs, +31%, standard error 0.4). No pool was left without a span, no span was refused, and --probe-vkmem passes with and without the imports.
2026-11-12 15:00:27 -05:00
Vasilis Manetas
677eb5c242 Keep an area load's texture staging inside the staging pool's blocks made at boot, instead of making about 1.1 GiB of new host memory in the load frame.
The staging pool keeps 4 blocks of 64 MiB from its first use on a discrete GPU (2 on an integrated one, as before). A staging buffer or arena chunk that does not fit them takes the room of the kept staging buffers and idle arena chunks, then of the oldest upload batches in flight, which it waits for; an arena chunk a waited-for batch gives back is taken instead of a new one. Only when that makes no room (the open batch's own staging, staging buffers their callers hold, the blocks' fragmentation) does the pool grow by a block, and the log says so. The outermost vk_upload_end submits the open batch once it holds 64 MiB of staging, besides the NX2NAVI_UPLOAD_BATCH_MB limit; nothing is submitted or waited for once the device is lost. Entering Hyrule Field made 221 staging buffers (1,131 MiB) in one frame; on Linux each new 64 MiB block is a kernel allocation that tries 2-4 MiB pages first, and with the free 2 MiB blocks used up the kernel compacted memory inside those allocations: 30-360 ms each, and the load frame took up to 4.7 s instead of about 0.3 s (RX 6800 XT, amdgpu, 32 GB).
2026-11-12 14:59:47 -05:00
chrg6
cacf458b08 Say in a lost device's report what the GPU was about to do when it stopped between two marks, and add draw marks.
A pass mark names the pass or dispatch the GPU had started and the last it finished. When the two are the same mark the GPU stopped in between, and the report named nothing: in a player's log of 0.9.2 (RX 6700 XT) both were one compute dispatch, with 51 later marks made and none started. The report now adds the mark that never started and the kinds of commands the replay recorded between the two (copies, clears, the upscaler, write-backs), which the gap notes already name for the GPU pass timing and now keep for every mark.

draw_crumbs = 1 (off by default, AMD buffer marks only) also writes a mark wherever the shaders or the pipeline state change inside a pass. The device losses reported on RX 6600 and RX 6800 class cards end inside one scene pass of 30 to 100 draws and the draw that hangs is not known; with the setting on, the report lists the shaders of the draws the GPU had not finished.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:57:21 -05:00
chrg6
d48a3d59f8 End a helper run with its own exit code when a library faults while the process is ending.
The shader preparation modes and the probes return from main, and the C runtime's exit then calls the detach of every library in the process, those other programs put there included. On a player's PC a post-processing injector's Vulkan layer read a null pointer there at the end of every --warm-shaders and --prepare-shaders, four runs over three versions: each printed "variants built into the driver cache", then a FATAL, and ended with code 71, so the launcher logged a finished preparation as one that did not finish. The crash handler now ends such a process with the code its mode returned and one "exit:" line; a fault while the mode still runs, or in the executable's own code on the way out, is reported as before. A crash inside that injector's library also gets the note that the library is not part of the game.

--probe-crash-exit makes the fault from an atexit handler, and tests/exit_fault_check.py checks both cases.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:57:13 -05:00
epicmeme546
a32597bc41 Move the Replay workers row under the Game tab's Testing heading, away from the save rows.
The Testing heading now shows on every game version, with the experimental Replay workers row first and the debug menu's toggle after it where the game version has the debug menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:54:43 -05:00
chrg6
ac3c43ba4d Make a small plain-format texture at its lookup, beside the creation budget.
The budget spreads the streamed, compressed pictures over frames. A texture that is not block-compressed is as often the game's data, a height map or a table, and a draw that reads the stand-in in its place for a frame misplaces geometry or lighting. Up to 24 of them a present, of at most 4 MiB each, are made at once; pool_data_first = 0 leaves them to the budget.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:52:26 -05:00
epicmeme546
94dd340fee Turn the replay workers off by default again, and set them in the settings menu's Game tab.
Players' games crashed with three workers on by default in 0.9.1 and 0.9.2: an RX 6700 XT lost its device after
about 18,000 frames, its last mark a compute dispatch of the replay, with an occlusion query slot found unavailable
after its fence just before. The cause is not known yet, so replay_workers is 0 again unless a player sets it. The new
Replay workers row (0 to 8, experimental) writes replay_workers and waits for a restart, as the other restart-only rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 14:48:47 -05:00
chrg6
e6392f381f Turn Link's own motion vectors on by default.
DLSS, FSR 3.1 and frame generation get the real motion of Link and his equipment unless selective_motion = 0 is set. Its two status lines come every minute or two instead of every two seconds, now that every session writes them.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:38:26 -05:00
chrg6
3123d40017 Offer the low-end GPU mode only on the cards it was written for.
On a card with mesh shaders and 8 GiB of video memory or more the Game tab's switch cannot turn the mode on; it stays usable while the mode is on, to turn it off again. A player with every setting at its highest had it on on a GeForce RTX 5090, where the mode moved the sun shadow map and the scene depth to memory of their own and the ground's shadow became a dark patch with straight edges that turned with the camera.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:28:36 -05:00