The user asked for the release to go out as 0.11.0. No final 0.10.2 was published: only the previews 0.10.2-rc1,
-rc3 and -rc4 carry that name, and the launcher counts 0.11.0 as newer than each of them, so testers are offered it
on either update channel.
VERSION holds 0.11.0 and the notes move to docs/releases/v0.11.0.md, with a line for those on a preview. Every line
that named 0.10.2 as the release in which something changed names 0.11.0 now: comments in the runtime, the two log
lines that give a version (the removed low-end GPU mode in cfg.c, the page generations in vk_memory.c),
nx2navi.ini.example, docs/building.md, the labels of tests/tex_release_check.c and a comment of
tests/audio_params_check.c. AUDIT.md names the release 0.11.0 throughout and records the earlier name and the
previews once, in its status line. Nothing else changes.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The memory report's system line asked for the heap's summary at every report until one was slow. The summary holds
the heap's lock, and every malloc of every thread with it, while it adds the heap up: 54 and 70 ms in two sessions
on an RTX 5090 PC, and the frame of the first report, 30 seconds into each session, took 55 to 82 ms, the longest
frame of play in both. One slow summary switched the question off, so every session paid for it once.
It is now asked for before play only (the boot and the title screen), and at every report with mem_census = 1,
which already accepts a late frame for the walk of the address space. In play the line gives what the runtime knows
it allocated, the same figure from one report to the next. The rule is in runtime/vk_mem_heap.h with
tests/mem_heap_check, which fails against the rule before.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The user asked for it: the mode no longer gains frame rate, and the mesh shader fallback covers the cards it was written for. Its switch in the settings menu's Game tab is gone with the rows under it, and so are their 57 text ids and strings.
low_end_gpu itself does nothing any more. It is still a known key, so an older nx2navi.ini gives no unknown-key warning, and while it is on the log says once that the mode was removed in 0.10.2. The preset that gave nine keys other defaults is gone, and so is the rule that read the mode's keys only while it was on: each of them (mesh_emulation, tex_clobber, foliage, vram_heap_limit, vram_reserve_mb, upload_batch_mb, mirror_stage, mirror_hot, submit_finish_gpu and the others) is an ordinary setting with the default of its row or of its reader, so a line a player wrote by hand keeps counting and a file with only the switch runs the game as it runs for everyone else. guest_opt in nx2navi.ini counts for the next rebuild without the switch too (tools/make_game.py).
The recorder thread read the switch itself: on an AMD card without mesh shaders the mode turned it on. It is off on every AMD card now unless vk_recorder = 1 is set.
nx2navi.ini.example lists the former mode's settings as single settings, the 0.10.2 notes have a Removed entry and name the foliage fix of the commit before this one, and the comments that said what the mode sets say so in the past tense. tests/cfg_parse_check has the new reading of the switch and of the keys without it; the test matrix is at 90 ok with the same two not ok as before (installer_test, worktree_test), and a clean build compiles 169 runtime files and links. Not run in the game.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A play session at 5120x2160 held 3 to 5 GiB free inside 140 to 180 of the allocator's 64 MiB blocks, 48 to 85 of them at most half full, and the "mem: holes" line said only that 1 to 18 of those could be emptied by moving textures. A second line now counts the other half-full blocks by what stays in them: a VRAM mirror, a texture whose content only the GPU made, a texture a shader sampled lately, or a texture that cannot move for another reason, each with the blocks that kind alone keeps. Those figures say which change would give the memory back: mirrors placed apart from textures, or a wider rule for what a compaction may move. The line is written with the holes line, every 3,600 frames, from the table that line already builds.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The review found that vk_memory.c mirror_evict picks its victims before it fetches their armed ranges without
pool_lock, and skips a victim vk_pool_fini took off the mirrored list meanwhile. The bytes freed then fell short of
what the caller needed, and the candidates after the victims were never tried, where the loop under one lock had moved
on to the next. Now every candidate's range and mirror is kept, and the loop under the lock goes on through the
candidates until the bytes are freed; a candidate past the first victims has its armed ranges fetched under the lock
there, as every victim's were in 0.10.1. hot_check demotes every pool that qualifies, up to eight, so it has nothing
to make up: its comment says that a candidate finalized meanwhile is not replaced, since its mirror goes with the pool.
Rare, needing a finalize between the two locks; compile check only, as an eviction needs a device. Not reproduced
before the change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review found that the comments on frz_lock (memory.c, above FRZ_SHIFT), lazy_fly_run and vk_memory.c lazy_fetch
said a fetch runs under frz_lock only in a mapping change's thaw or with NX2NAVI_WB_LAZY_UNLOCKED=0. In fact
thaw_begin_locked's lazy_drop_region_locked runs in every thaw under the lock: a mapping change, the lazy heap's commit,
and thaw_fault on a write lazy_fault did not take. lazy_drop_region_locked's own comment said "a write fault in a
frozen region", but a frozen region holds no armed ranges. And the LZ_SLOTS comment called four slots more than the
threads that touch armed pages together, where the reference session had six such threads and the flight table holds
64. The comments now give that list of thaws, and LZ_SLOTS says that fetches past it wait 20 us at a time for a slot.
Comments only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the setting at 0, frz_lock was held across every fault's and release's fetch again and mem_thaw released under the
lock, but two parts of the new code stayed. vk_memory.c mirror_evict and hot_check still fetched their victims'
armed ranges before taking pool_lock again, and a release still put up to 16 copies in one submission. Those are the
parts that run only with a device and have no check without one, so a device loss caused by them was the one case the
switch could not take back. Now vk_memory.c reads the setting too (evict_unlocked, once on the replay thread, where
both run): at 0 neither drops pool_lock, and the release under it fetches everything, victim by victim, as before. And
memory.c lazy_take_batch_locked takes one segment at a time at 0, so each is fetched on its own.
What stays at 0 is the table of ranges in flight: a touch of a range being fetched waits for that fetch and runs
again (a write no longer goes on to thaw_fault and releases its region), the hold is set as the range goes out, and
mem_lazy_touches counts the range. memory.c "Ranges in flight", nx2navi.ini.example and docs/building.md say so.
tests/lazy_fetch_check.py gains locked_release, the release scenario at 0: three fetches of one copy each, where the
batched release makes two of up to two copies. It fails with the cap taken out. The eviction's side has a compile
check only. Not reproduced before the change; lazy_fetch_check passed in all its scenarios.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CPU touch of an armed range ran its fetch (vk_memory.c lazy_fetch: a GPU copy after everything handed over, and a wait of up to 10 s) inside the fault handler under frz_lock. Every other fault, thaw, freeze and arm waited for the whole copy meanwhile: the replay's copies of guest memory (mem_copy_readable, vk_memory.c mirror_capture) and its freeze pass, the completion thread's arms, file reads and pool finalizes. A write that lost the race to the fetch went to thaw_fault after it and released every other armed range of its 2 MiB region, a second fetch. mirror_evict and hot_check fetched their victims' ranges under the exclusive pool_lock, which the completion thread's arms and every pool lookup wait for. This is AUDIT F-4-05 (C-12); the design's probe of the unchanged code had an unrelated mem_copy_readable, arm and freeze each wait 299 ms behind a 300 ms fetch, and the losing write 378.8 ms with two fetches.
A range now leaves the armed list into a table of ranges in flight (memory.c "Ranges in flight", lz_fly) in the same lz_lock hold, and stays there, its pages inaccessible, until its bytes are down and its pages read-write again (lazy_fly_run), without frz_lock. A fault on a range in flight waits for that entry alone on lz_lock's condition and runs the access again, the losing write included; a thaw, a mapping change, a release of its buffer, an arm, a freeze and mem_lazy_touches see it. Every store to lzr is made under lz_lock from the list, the entries and the region an arm is protecting (lz_arm_r, which replaces the previous commit's restore). An entry starts under frz_lock and lz_lock and ends under lz_lock alone, so a holder of frz_lock may wait for one: its fetch takes only the queue lock, which no frz_lock taker holds. mem_thaw releases what is armed in its regions before it takes frz_lock (lazy_release_regions), so file reads and pool finalizes fetch without it; a mapping change's thaw still fetches under it, as its page calls follow. The holds a fault sets (lz_hot, lz_hots) are now set as the range goes into flight, so an arm meanwhile finds them. The fetch takes several copies in one submission and one wait (MemLazyCopy), and the back end has four slots of command pool, command buffer and fence, so fetches of different ranges run at once; a release takes up to 16 armed ranges out together.
mirror_evict and hot_check now fetch what only their victims' mirrors hold before taking pool_lock exclusively, and then, under the lock, release only what was armed since (usually nothing) before the mirror leaves the list and the device address table names the imported buffer again. The design moved the release after the lock instead, which would have had the table change before the fetch: a shader of a submission still running reads through the table as it changes, and the fetch waits for every submission handed over. A victim that vk_pool_fini took off the list meanwhile is left to it; hot_check copies its log line's fields under the lock, as its records may be freed once the lock drops.
The new setting wb_lazy_unlocked (NX2NAVI_WB_LAZY_UNLOCKED, default 1, restart) set to 0 keeps frz_lock from the take-out to the end of each flight and leaves mem_thaw's releases to the thaw, as in 0.10.1; the table, the slots and the eviction order stay. The "mem: lazy write-back" line counts segments fetched; a second line gives the touches that waited for a range in flight, the longest such wait and the most ranges in flight at once; "mem: sizes" counts fetches that found every slot busy. The frz_lock and lz_lock comments say what holds what now (B-12). mem_lazy_touches has callers outside memory.c (camera_speed.c, nvn_api_cmd.c ctl_fingerprint), which now skip a range in flight instead of faulting on it and waiting.
Not reproduced in the game before the change. tests\lazy_fetch_check.py now runs 20 scenarios as their own processes: arm races, no stall of freezes, copies, arms, thaws and releases of other ranges during a fetch, joins with frz_lock held elsewhere, a losing write that keeps the region's other range armed, two fetches at once, a thaw, a mapping change and a release meeting a range in flight, an arm refused over one, the foreign give-back, the chunk mode, the old wait with wb_lazy_unlocked = 0 (joins and losing writes as well), the report lines, 80 touching threads past the 64 entries, and a freeze refused; all pass, in six runs. Taking the join before frz_lock out fails the join scenario. cfg_rows_check reports 0 problems; every runtime file compiles, and memory.c and vk_memory.c add no -Wall -Wextra warning; the test matrix passes 75 of 77, installer_test and worktree_test failing for the environment as before. vk_memory.c's slots and the eviction's order have no check without a device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether the 64-bit generations hold past 2^32 in the game, with every texture check, mirror sync and shadow copy of a
real scene, could not be seen in play: reaching 2^32 takes 15 to 66 hours. gen_wrap_check shows it for the generation
code alone. NX2NAVI_GEN_SEED=<n> (nx2navi.ini gen_seed, diagnostics, default 0) makes the run's first generation
n + 1: with 4294966000 a run passes 2^32 within its first seconds, and with 1099511627000 the low 32 bits pass
through 0 again.
The first gen_next, before anything is stamped, reads the value (gen_seed). It is parsed with strtoull, decimal or
0x hexadecimal: strtoul stops at 2^32 - 1 where unsigned long is 32 bits, as on Windows, and would turn a seed past
32 bits into 4294967295 without a word. It is capped at 2^62, far from the 64-bit end, and only ever raises the
counter, since a lower value would leave stamps newer than it that read as written at every check. A value that is
no number, or negative, is logged and ignored; a seed in use is logged as "mem: page generations start at <n>
(NX2NAVI_GEN_SEED, a test switch)". Without the switch the only cost is one compare a generation that is true once.
cfg.c gets its metas row (0, 0..4611686018427387904, read at start), its known[] name and its sections[] entry, each
on a line of its own next to the other memory test switches (STAGING_FAIL_AT, VRAM_BALLAST_MB); nx2navi.ini.example
gets a commented gen_seed entry under [diagnostics]. The settings menu does not show it.
tests/gen_wrap_check runs the generation code a second way: the counter left at 0 and the seed taken from
NX2NAVI_GEN_SEED, for 4294967288, 0xfffffffff8, 0x7ffffffffffffff8 (capped), 12abc, -8, 0 and unset, each with the
start and the log line expected of it, then the same compares across the boundary. With strtoul in place of strtoull
two of those runs fail. Checks that passed: gen_wrap_check, gen_width_check, cfg_rows_check (0 problems),
pass_cc_check on vk_memory.c and cfg.c, no new -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tables of page write generations take 8 bytes a page since the generations became 64-bit: each 256 MiB chunk of
guest address space stamped is 512 KiB a table instead of 256 KiB, and reg_max and creg_max are 2 MiB each. The
estimate for an outdoor area was 27 MiB of chunks at most, with less resident, but nothing in a log said how many
chunks a session allocates. slot_in now counts them (under gen_lock, an atomic add read without the lock), and
vk_mem_sizes adds "page generation table chunks N, X MiB" after "page generations handed out": the chunks of both
tables and the two per-region arrays, as allocated (only the pages that hold stamps become resident).
Only trace=perf logs the line; slot_in allocates a chunk a few dozen times a session. Checks that passed:
pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings, gen_wrap_check, gen_width_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_memory.c handed out page write generations from a 32-bit counter (gen_now) and every consumer compared them as
32-bit values, with 0 meaning "never stamped". At 124 to 336 generations a frame (a 0.10.0 log with trace = perf) the
counter wrapped after about 66 h of play at 60 fps, 27.6 h at 144 and 15 to 17 h at 240. Past the wrap a stamp made
before it compared newer than every later check and one made after it older: textures and VRAM mirrors missed the
CPU's writes (stale textures, and the lazy fetch copying older mirror bytes over a CPU write), pages nothing wrote read
as written at every check (a hash or upload every time, and every such page of a mirrored pool copied up at each
sync), and the generation handed out at the wrap was 0. At 64 bits nothing wraps: 2^64 lasts millions of years at
336 a frame and 240 fps. Below 2^32 every value and every compare is the same as before.
What changed: gen_now, both tables (gen_chunk, cgen_chunk), reg_max and creg_max, PoolRec's synced_gen, mgen and sgen,
dirty_gen and DIRTY_UNSYNCED, VSample.mgen, every local and parameter that holds a generation in vk_memory.c,
vk_texture.c (Tex.wgen and gpu_gen, PEnt.since, storage_rewritten and its callers, sweep_hard's write-back check
included) and tex_probe.c, and vk.h's vk_mem_changed, vk_mem_changed_snap and vk_mem_unchanged(_why)_locked. The verify
mode's mismatch line prints the generations with %llu. The readers that take no gen_lock (lazy_clean, cpu_wrote_since,
verify_cb, vk_mem_sizes) stay lock-free at 64 bits: the slots, mgen, gen_now and a new chunk pointer are now stored
atomically (relaxed, or release where a reader acquires), the same instructions on x86-64, and a static assertion
holds that 64-bit atomics are lock-free. No lock, wait or thread changes.
gen_next, which hands out every generation, no longer warns at 0xC0000000 and up or at the wrap, which cannot come;
it logs one line when the count reaches 2^32, where earlier versions ran out, with the frame, the hours and the rate.
Cost: the tables take 8 bytes a page instead of 4 (512 KiB more per 256 MiB of guest address space stamped, both
tables together, allocated as before when a chunk is first stamped), reg_max and creg_max 2 MiB each instead of 1,
Tex and PoolRec 8 bytes more, PEnt 8 more. The page walks read 8 bytes a slot; a micro-benchmark of the walk measured no
difference.
Not reproduced in the game before the change: the wrong answers were reproduced by compiling vk_memory.c's generation
code with the counter seeded below 2^32. tests/gen_wrap_check now runs that code, with the readers outside its
section, across 2^32, 2^40 and 2^63 and checks that the compares answer as below the boundary; on the code before the
change 19 of its checks fail at 2^32 and the other two seeds do not fit the counter. The new tests/gen_width_check
compiles vk_memory.c, vk_texture.c and tex_probe.c with -Wshorten-64-to-32, -Wformat and -Wincompatible-pointer-types
as errors, so a 32-bit local left behind is found (shown by reverting single locals, fields and formats). Checks that
passed: gen_wrap_check, gen_width_check, pass_cc_check of every runtime file, no new -Wall -Wextra warnings in the
three files, tex_release_check, tex_compact_check, wb_lazy_check, gpa_check, mem_growth_check, mem_budget_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of the B-4 change found that nothing measured the pool destroys any more. Before it, pool_retire ran
destroy_rec inside a completion callback, which trace=perf timed per callback; now pool_dead_drain runs the destroys
untimed, and the line only counted them. The drain also runs on whichever collect swaps the list out first, so part of
the work can fall on the game's thread in pool_fini's vk_mem_collect, and the play-test the finding plans could not tell
where the cost went.
pool_dead_drain now times its destroy loop with plat_now_ns and keeps the records, their time and the longest drain
apart for the completion thread's vk_mem_collect_try and for vk_mem_collect, whose only caller is pool_fini. The trace=perf
"mem: sizes" GPU line gives them since the last line: "of them since the last line: N on the completion thread (X ms,
longest drain Y ms), M by pool_fini (X ms, longest drain Y ms)". Diagnostics only: two clock reads per drain that
destroys something, and no lock or wait added.
Checks that passed: pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings, sync_hw_check, gpa_check,
mem_budget_check, tex_release_check, wb_lazy_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_memory.c hands out page write generations (gen_now, 32 bits) with no wrap handling. Past the wrap, textures and
pool mirrors the CPU writes can miss a write, and every check reads as a write. At about 181 generations a frame that
takes about 110 h at 60 fps or 27 h at 240. Only trace=perf's "mem: sizes" counted them, so nothing in a player's log
could tell a "textures went stale after a day" report from other causes.
The nine "++gen_now" sites now call gen_next, which hands out the next generation (gen_lock held exclusively, as at
every site). When the value reaches 0xC0000000, 0xD0000000, 0xE0000000 or 0xF0000000 it logs the count, the frame,
the rate per frame and about how many hours of play are left at that rate. At the wrap it logs that the generations
ran out. That is five lines at most a session and one predicted branch per generation. The log queue takes no lock
of vk_memory.c, so logging under gen_lock is safe. The wrap handling itself waits for approval.
The new tests/gen_wrap_check extracts the section from the "page write generations" banner to the "dirty census"
banner and runs it with stubs. It checks one line at each mark and at the wrap and none elsewhere, and the first
line's rate and hours. It also expects the four comparisons that are known to answer wrong across the wrap, and says
so: the change that handles the wrap turns them into failures there and flips them. On the code before this change
the check fails (no lines). Checks that passed: gen_wrap_check, pass_cc_check on vk_memory.c, no new -Wall -Wextra
warnings, gpa_check, mem_growth_check, mem_budget_check, wb_lazy_check, tex_release_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
completion_main calls vk_mem_collect after it publishes each new done, and vk_mem_collect waited for cmd_lock. An
upload flush holds that lock across vk_queue_lock, behind a present call (FIFO, a frame limiter inside the present,
frame generation) or another long queue-lock hold. With wait = 1 it also holds it across its batches' fence waits,
and stg_create waits for batch fences under it. The single completion thread blocked there could not process the next
timeline value, so the next done, and every fence waiter for it, waited for that chain.
vk_mem_collect_try only tries cmd_lock. When the lock is busy it still destroys the finalized pools, which need only
retire_lock, and leaves the deferred objects to a later completion or to the next upload flush's own collect. After
eight busy tries in a row it waits as vk_mem_collect does, which keeps the deferred list bounded, as before. pool_fini
keeps the waiting vk_mem_collect. The trace=perf "mem: sizes" GPU line counts the collects that gave way.
Not reproduced before the change: the completion thread's lock waits are not timed. Checks that passed:
pass_cc_check on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_memory.c and vk_sync.c,
sync_hw_check, gpa_check, mem_budget_check, tex_release_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pool_retire destroyed the record at once (destroy_rec: vmaDestroyBuffer of the mirror, vkDestroyBuffer and
vkFreeMemory of the imported memory, which the driver unpins). It ran in two places, and both were costly. In the
completion thread's callbacks it ran before done is published, so every fence waiter waited for those frees: 717.7 ms
in 7,250 calls in the 0.10.0 session, up to 76.8 ms within one 240-submission window at an area change. From
pool_wait_collect it ran under cmd_lock, in the upload flush of whichever thread collected first, such as the replay's
per-submission vk_upload_flush or the game's presenting thread. Every upload and deferral waited on cmd_lock
meanwhile, and a slow frame showed the time as unattributed.
Now pool_retire only moves the record from the retiring list to a new pool_dead list (retire_lock). vk_mem_collect
alone drains that list, after its cmd_lock section and holding no lock. The completion thread calls vk_mem_collect
right after it publishes done and wakes the waiters, and pool_fini calls it for its own frees. collect_locked
(vk_upload_flush, shutdown) never drains the list. vk_mem_shutdown destroys what is left on it, after its
vkDeviceWaitIdle. The trace=perf "mem: sizes" GPU line counts the records destroyed this way.
Not reproduced before the change: the cost was summed from the completion thread's per-callback lines of the 0.10.0
log, not measured on a frame. Checks that passed: pass_cc_check on vk_memory.c, no new -Wall -Wextra warnings,
gpa_check, mem_budget_check, tex_release_check, sync_hw_check, wb_lazy_check, mem_growth_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mirror_create put the pool on mlist under pool_lock, released the lock, and only then set acc_sub, mstate = MS_ON,
asked_room and twin. It also took pool_lock again for the device address table's hand-over (gpa_flip) or, with
NX2NAVI_GPA_LATE=0, the remap. pool_fini calls mirror_forget only when it finds MS_ON. A finalize on another thread
in that gap therefore left the record on mlist, and destroy_rec freed it and its buffer without taking it off the
list. The next hot_check, mirror_evict, vk_mem_mirror_begin or gpa_flip_cmds then read freed memory, and
mirror_evict could defer the freed buffer a second time. With GPA_LATE=0, the table record then named the destroyed
buffer. Now one hold covers the finalize check, the insertion, acc_sub then MS_ON, asked_room, twin, and the
hand-over or remap. twin_wanted and gpa_late are decided before the lock is taken, since each may read its setting and
log once. The untwinned counters are atomics and stay after the hold.
Not reproduced before the change: no check without a device can drive the race. Checks that passed: pass_cc_check on
vk_memory.c, no new -Wall -Wextra warnings, wb_lazy_check, gpa_check, mem_growth_check, mem_budget_check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_astc_scratch_trim runs at every present and held the decoder's lock while vk_defer_buffer waited for cmd_lock.
The upload path takes the two locks the other way round: vk_upload_begin takes cmd_lock, then vk_astc_record and
scratch_get take the decoder's lock. A present that trimmed while another thread was between vk_upload_begin and
scratch_get therefore hung both threads. Without the hang, the present still waited for as long as an upload flush
held cmd_lock through its fence waits. vk_memory.c gets vk_defer_buffer_try, which defers only when it gets cmd_lock
at once. When the lock is busy the trim leaves the scratch as it is, and the next present tries again.
tests/astc_trim_check builds the real vk_astc.c with platform/common.c's recursive lock. It checks that the trim gives
way while another thread holds cmd_lock, that it goes through once the lock is free, and that the forced interleaving
ends. Before the change the check reported the deadlock (exit 2). Checks that passed: astc_trim_check, pass_cc_check
on every runtime file (vk.h changed), no new -Wall -Wextra warnings in vk_astc.c and vk_memory.c, and the pass's
test matrix (35 ok; the 6 failures are the baseline's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of c05e6528 found that vk_memory.c census_main printed the walk's start and end as kern_now_ns / 1e9, which
is plat_now_ns: seconds since Windows started. No other log line uses that clock (the stutter lines give seconds since
the first frame, the slow-frame and present lines frame numbers), so the walk could not be set beside the frames it held
up, which is what a play-test with mem_census = 0 needs to tell the walk from the presenting thread's report as the
cause of the 600N+1 hitches. census_main now reads span_frame, the frame vk_mem_census sets at every present, beside
each end of the walk and logs "the walk from frame F0 to frame F1, N ms": the numbers the "stutter: frame" and
"perf: slow frame" lines carry. The walk's length stays.
Diagnostics only: the line is logged with NX2NAVI_MEM_CENSUS = 1, which defaults to 0. Not reproduced (it needs a game
run). No tool or test reads the line. vk_memory.c compiles with the dev flags and adds no -Wall -Wextra warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With mem_census = 1 and trace = perf, vk_memory.c census_main walks the address space for 100-240 ms on its own
thread, holding the process's address-space lock. The replaying thread's write-watch queries wait for that lock
("mem: slow write-watch query: 20-34 ms ... 0 pages written"), and ft_frame charged the frame to "replay": in the
0.10.0 fresh session (nx2navi.1.log, 7,974,927 bytes) 63 of the stutter map's 66 replay hitches sat beside such a
walk, 49 of them at frames 600N+1..3. The presenting thread's own 600-frame memory report (10-17 ms, timed in
vk_mem_census since b81a9561) went to "unattributed". And at frame 3600N, census_check walked on the presenting thread
and the same frame's heap_log started census_main's second walk.
- perf.c perf_census_walk brackets census_main's walk (atomics); ft_frame moves the part of a frame over 25 ms (or a
stutter-map candidate) that overlapped the walk out of the replay part first, then out of the unattributed part,
into "census". The rule is runtime/perf_census.h census_charge, capped by the overlap and the frame.
- A new cause "report": vk_mem_census feeds the report's time (perf_report_part), and so do perf.c's memory
snapshots and its 600-frame sizes lines; the next frame takes it, so the overlap rule no longer has to infer it.
The cause list changes together: perf.c FT_C_* and ft_cause_name, stutter_map.c SM_CAUSES and cause_short,
tests/stutter_hitch_check.c names[].
- census_check records the frame it walked (perf_census_walked_frame); heap_log does not start census_main for that
frame, so frame 3600N walks once. A frame stamp, not a time window, since prof_commit_census can fail and leave
perf_note_wc uncalled.
- The "mem: process" line gives the walk's start and end on the run's clock and its length, so a play-test with
mem_census = 0 can tell the walk from the presenting thread's report as the cause of the 600N+1 hitches.
New tests/census_charge_check.py builds runtime/perf_census.h alone: no overlap, partial, contained and still-running
walks, the charge from replay first, capped by the overlap and the parts, and 100,000 random frames in which the census
part never passes the frame; docs/building.md describes it. Diagnostics only: NX2NAVI_MEM_CENSUS defaults to 0, and
nothing in gameplay, pacing or the save changes. Not reproduced before the change (it needs a game run). The pass's
known-issues table, which should say that hitch counts are taken with mem_census = 0 and trace off, is not in the
repository. census_charge_check, stutter_hitch_check and stutter_map_check pass; every runtime file compiles with the
dev flags, and perf.c, stutter_map.c and vk_memory.c add no -Wall -Wextra warnings.
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>
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>
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>
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 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>
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>
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>
The line freed the upscaler stand-in's scratch images, FSR 1's and SMAA's images and the DLSS sizes on its own while
this pass did: its versions stay. Of this pass's versions, what they do not cover stays: the stand-in's views of a game
image it read as it is now go with that image (vk_tonemap_forget from vk_exec_image_gone, under a lock), and the
upscaler release frees only what the line's idle release leaves, the reactive mask's snapshots, the FSR upscale
contexts and the DLSS sizes after a failure, at the same 600 presents and with vram_use = high keeping them as well.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mirror is made on the replay thread after a look at the pool index; a pool finalized on another thread in between
had no mirror to forget, and the new mirror then joined the list for a record already retired: freed twice, or
never. The index is searched again under the exclusive lock where the mirror joins the list, and a pool that is gone
has its new buffer deferred past the submission being recorded instead. The count of mirrors waiting for their table
hand-over is raised under the same lock that the finalize and the prologue take it down under.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every VMA allocation is counted under the file that made it until it is freed, so the new "mem: by owner" line
splits the device-local memory the driver reports into textures (live, GPU-drawn, idle, released, released too
lately to drop), pools and VRAM mirrors, upscalers, replay buffers, uniform snapshots, the device address table,
the ASTC decoder, present and post-processing, headroom spares, the free space inside VMA's blocks and what VMA
never allocated. "mem: host" gives the host-visible allocations, the process's commit and working set and the
game's heap. "vk: released textures" counts take-backs by how long the texture was released and textures made
again after a drop by a bound or a pressure, with the drop's reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window's close ends the process under the detached threads, so a dump file the flight recorder's thread was
writing stayed cut short under its own name. Its three files are now written as .part and renamed when complete
(runtime/part_file.h, with a check that kills a writer in the middle), and the allocator's map goes through
plat_file_write_cached. The shader identities and the other cache files already went through an atomic write.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
With the setting on, textures no shader sampled for ten seconds are copied on the GPU out of the emptiest allocator
blocks into the holes of fuller ones, so the emptied blocks go back to the driver. A pass starts when the free space
inside the device-local blocks reaches NX2NAVI_TEX_COMPACT_MIN_MB (one block under VRAM pressure) and the block
table has blocks that hold nothing but such textures, with room elsewhere for what moves. It takes at most four
textures and NX2NAVI_TEX_COMPACT_FRAME_MB a frame, and none in a frame that loads an area, already made or uploaded
textures, or follows a long one. A released texture the size cap would drop is dropped instead of moved.
Only a texture whose image holds what the game's memory holds moves: uploaded, never written by the GPU, single
sampled, not depth. The entry takes the new image under the texture lock and its address generation moves, so every
slot, pool descriptor and memo resolves again. The old image and its views stay until the texture pool has taken
every set of its ring and the GPU is past the submissions recorded until then, as a retired texture's do.
Each pass logs what it moved and dropped, the blocks and bytes given back, and the time it took. A block emptied of
textures that the allocator keeps is left alone for ten minutes, and a pass that gave nothing back doubles the wait.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A log without the perf trace said nothing about the free space inside the allocator's device-local blocks, which the
driver counts as in use. Every 3600 frames (600 with the perf trace) a "mem: holes" line now gives the blocks and the
allocations in them, and for the memory type the textures use most the blocks that hold a texture, the textures and
VRAM mirrors in them, the blocks at most half full, and those of them that hold nothing but textures that could be
copied elsewhere or dropped: emptying those is the most a compaction could return.
The counting is runtime/vk_tex_compact.h, which also chooses the blocks to empty, with tests/tex_compact_check.py.
A texture entry keeps the block its memory lies in, so the table is built without asking the allocator per entry.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A staging buffer that does not fit the staging pool's blocks waits for the oldest upload batch with the upload lock
held, and a replay submission that sends its uploads waits for that lock. The wait cannot leave the lock: the caller
holds the open batch's command buffer and the buffer it asks for belongs to that batch. What the wait costs on an area
load was not measured anywhere, so it is timed and logged now: the first four waits, then at every doubling of their
count, with the total and the longest.
Since staging stays inside the pool's blocks every batch is submitted at 64 MiB of staging, so upload_batch_mb = 0 is
that limit and not "no limit", and the values above it (the low-end mode's 256, the menu's 128 to 1024) change
nothing; a discrete card keeps 256 MiB of staging for the run, not 128. The settings table, the menu's text,
nx2navi.ini.example, the audit's table and the comments say so.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The table still named a new mirror at record time, as the pages' twin: parts already handed to the queue wrote
through the table into the new block before its first copy, the same early access the delayed read was made to stop.
The reads then moved to the mirror some submissions later by a second change that took a second record, cleared its
note before it knew the record was there, and until it ran left the table's readers on guest memory, without what
other shaders had written into the mirror through a buffer binding.
Nothing is changed when the mirror is made now. The prologue that holds the mirror's first copy also holds a store
into the pool's record, after the copy: what was queued before it reads and writes guest memory through the table as
if no mirror existed, what comes after reads the mirror and writes both. No second record, no second change, no
window in which the table is behind. gpa_late = 0 still changes the table at once, as up to 0.9.2.
--probe-vkmem makes a mirror in a recorded submission and reads the table's own memory back: the page names the
mirror with guest memory as the twin, and the mirror holds the pool's bytes. tests\gpa_check.py runs the store as the
GPU would, before and after. A table with no record left now says so once in the log.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The game waits each frame until the GPU has finished the frame before. In Hyrule Field with FSR 4 the last of that
work is one part of 4.7 ms, the temporal upscaler and the interface drawn after it, then 1.4 ms of write-backs, and
what the game reads of the frame is done before the upscaler runs. With NX2NAVI_TAIL_EARLY the replay records that
tail into a command buffer of its own, and when it hands the CPU nothing but timestamps, writes no pool memory but
inline uniform updates of mirrored blocks and reads no guest memory late, the write-backs go to the queue before it
with the executed timeline's signal. The game's waits end there, and the GPU runs the tail while the next frame is
prepared. The GPU's commands are the same; a tail that fails a test goes as before. Off by default.
RX 7900 XT, Ryzen 7 5800X3D, 3440x1440, FSR 4 Native AA, one batch, replay workers off: 50.7 against 42.5 fps
standing and 56.0 against 45.9 along the field route; with FSR 3.1, 51.8 against 47.8 and 54.9 against 52.3. With
three replay workers, FSR 4, standing: 63.5 against 49.4. The screenshots matched those without it standing, along
the route and through warps to five areas, with no black frame outside the loading screens of both.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
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>
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>
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.
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).
Mesh shaders as compute, the texture that gets memory of its own, staged mirror uploads and the shorter wait for the GPU, widened write-watch queries, the video memory limits and reserve, early upload batches, the asynchronous replay, the hot-pool rule and the recording thread on AMD cards without mesh shaders were measured on a Radeon RX 580 and run on no other card. Each keeps what 0.9.1 did unless the key or its own setting says otherwise; the key gives the defaults the way the device profile does. The notes describe them as that option, hold main's two new entries in one Changed section, and list the white edges in towns as a known issue.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The three change what every card does: the asynchronous replay can show a dark or flickering frame now and then, the hot-pool rule moves pools into system memory on every discrete card, and -O2 makes each rebuild of the game several times longer. Their rows stay in the settings tab. The USB joystick mapping is described by the device's own name, and the tab's introduction says what its settings are for.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Conflicts: the settings key lists take both sides; frame generation keeps the camera-cut handling and logs its counters every 600 frames with the cuts among them; the pass-time mark moves into draw_viewport; mo_desc keeps the pose depth and notes the reader.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
At every submission that binds the texture pools the replay checks each listed texture for CPU writes, and takes the write-watch snapshot of the 2 MiB regions that have none yet as the list comes to them: one query a region, since the list is in no address order. Standing still that is 5 to 10 queries and the check takes 0.11 ms. While the player runs through Kakariko or Castle Town the game writes about 32 textures into new storage every four or five frames, a region the CPU wrote is not frozen again for 1,800 submissions, and the check asked for 100 to 500 regions one by one, 2.7 times a frame: 0.5 to 1.5 ms a check, about 3 us a query, in five sessions of one player. The regions are now collected for the whole list first and asked in one query per run of adjacent regions. On this machine 400 queries of 2 MiB take 727 us and one query over the same 800 MiB takes 360 us. The same regions are queried and the same pages stamped, all of them before the list is looked at, as each entry's were before its own look; a run across the edge of a watched reservation is left to the queries by range. pool_presnap_runs = 0 asks region by region as before.
A released entry stays in the list until it is destroyed, the check does nothing for it, and the stamp it compares stays as it was at the release, so one whose region took a write later missed the region check at every submission from then on and was fetched only to be passed by: 1,000 to 6,300 entries a check, with up to 8,192 released entries kept. The list now carries the released state, set at the release and cleared at the take-back, and the check passes such an entry by without touching it or asking for its regions.
The pool log's freshness line counts the released entries passed by, and its snapshot queries count the queries either way. --probe-vkmem has a case for the queries by run.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A pool read in place gives the GPU the game's bytes as they are when it reads them. The demoted pools were ones the
CPU refills every frame, which the game rewrote for the next frame while the replay and the GPU still drew the last:
small grass and moss flickered and slid over the ground, and with 0.8.0's mesh fallback every mesh flashed. Pools with
more than 64 KiB of CPU writes a frame (written pages and flushes) now stay mirrored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>