From bd94adc9ecddca4374dff350cfcc0ca1d9614b3a Mon Sep 17 00:00:00 2001 From: epicmeme546 <178368684+epicmeme546@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:09:55 -0500 Subject: [PATCH 1/3] Stop the launcher's animations while another window is in front, so a launcher left open behind the game no longer draws every 15 ms. With Hide Launcher While Playing off, the launcher stayed in its normal state behind the game and kept stepping its animations: about 0.6 of a CPU core went to a window nobody saw, beside the game's render thread. The animations now also stand still while the window is not in front, the same check the gamepad polling uses. Co-Authored-By: Claude Opus 5.5 --- docs/launcher.md | 3 ++- launcher/ui/app.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/launcher.md b/docs/launcher.md index b05fdb6f..e41048a8 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -55,7 +55,8 @@ Customize Launcher (on the home screen) makes the look the player's own: scheme's backdrop back. - Animations (on by default): the cursor glides from row to row, a new screen slides into place, sparks drift up over the backdrop and Navi bobs in the title band with a pulsing halo (`App.animate`). They move by the time that - passed, stand still while the window is hidden or minimised, and Off stops them all. + passed, stand still while the window is hidden or minimised or another window is in front (the game, when Hide + Launcher While Playing is off), and Off stops them all. - Sounds (Low by default; Off, Medium or High): two of the game's own interface sounds, the decide and cancel sounds of its menus, when a row is chosen or a choice changes and when the player goes back. The launcher decodes them from the player's built game or dump into `data/cache/sounds` (`gamefiles.extract_sounds`: diff --git a/launcher/ui/app.py b/launcher/ui/app.py index 3691d541..93007a4d 100644 --- a/launcher/ui/app.py +++ b/launcher/ui/app.py @@ -1994,14 +1994,16 @@ class App: def animate(self): """One step of everything that moves: the cursor on its way to the focused row and its breath, the screen sliding into place, the sparks and Navi. Each moves by the time since the last step (the timer comes when - Tk gets to it, 16 to 50 ms apart), at most a tenth of a second's worth. With Animations off, and while the - window is hidden or minimised (the game is running), nothing moves and the timer runs slowly.""" + Tk gets to it, 16 to 50 ms apart), at most a tenth of a second's worth. With Animations off, while the + window is hidden or minimised, and while another window is in front, nothing moves and the timer runs slowly. + The last covers a launcher left open behind the game (Hide Launcher While Playing off): drawing every 15 ms + took about 0.6 of a CPU core from the game for a window nobody saw.""" moving = False try: now = time.perf_counter() dt = min(0.1, max(0.0, now - self.anim_last)) self.anim_last = now - moving = self.animated() and self.root.state() == 'normal' + moving = self.animated() and self.root.state() == 'normal' and winsys.in_front(self.root) if moving: self.anim_t += dt self.step_slide(dt) From 7eee4817f94529d4e66d3b3ef99fafca440e7764 Mon Sep 17 00:00:00 2001 From: epicmeme546 <178368684+epicmeme546@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:10:15 -0500 Subject: [PATCH 2/3] Split the uniform snapshot's copy over the replay workers at the start of a submission. The workers have no chunk to record while the snapshot copies the uniform ranges a submission binds (about 700 regions, 0.7 MiB in Hyrule Field), so the replay thread now hands them slices of it through vk_exec_par, a loop the workers take between groups. Without workers the copy runs on the replay thread as before. Co-Authored-By: Claude Opus 5.5 --- runtime/vk.h | 4 +++ runtime/vk_exec.c | 61 ++++++++++++++++++++++++++++++++++++++++++++-- runtime/vk_usnap.c | 36 ++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/runtime/vk.h b/runtime/vk.h index ca6724f8..292f4453 100644 --- a/runtime/vk.h +++ b/runtime/vk.h @@ -550,6 +550,10 @@ int vk_exec_in_chunk(void); // the calling thread records a chunk of a paralle // waits until the group's first chunk ran the pass hooks. int vk_exec_shared(void); int vk_exec_behind(void); +// The replay thread, while no group of the replay workers is out (vk_exec.c "side work"): fn(arg, lo, hi) over items +// [0, n) in slices of grain, on the workers and the calling thread at once; returns when every slice is done. Without +// workers, or for one slice, the caller runs it alone. +void vk_exec_par(void (*fn)(void *arg, int lo, int hi), void *arg, int n, int grain); void vk_exec_hooks_wait(void); // A pass hook (vk_contact_pass_begin, vk_dlaa_pass) about to change what draws read or to record: the submitting // thread past a group waits for the group first; a group's hooks run in its first chunk, in turn already. diff --git a/runtime/vk_exec.c b/runtime/vk_exec.c index 9728f5d5..c1c846bb 100644 --- a/runtime/vk_exec.c +++ b/runtime/vk_exec.c @@ -1879,12 +1879,66 @@ static int ser_kind(uint32_t op) { static Chunk *chunk_take(void); static void chunk_record(Chunk *c, int pool); +// ---- side work (vk_exec_par): the replay thread hands the workers a loop over n items while they have no chunk to +// record, at a submission's start (vk_usnap.c copy_regions). One job at a time, in a static slot: seq is odd while it +// runs. A worker counts itself in (PJ.in) and checks seq again before it reads the job, and the caller, once every +// slice is done, makes seq even and waits until no worker is in, so none reads a job that has ended. +static struct { + void (*fn)(void *, int, int); + void *arg; + int n, grain; + volatile int32_t next, left, in; + volatile uint32_t seq; +} PJ; +static PlatMutex pj_lock = PLAT_MUTEX_INIT; + +static void par_slices(void) { + for (;;) { + int lo = __atomic_fetch_add(&PJ.next, PJ.grain, __ATOMIC_ACQ_REL); + if (lo >= PJ.n) return; + int hi = lo + PJ.grain < PJ.n ? lo + PJ.grain : PJ.n; + PJ.fn(PJ.arg, lo, hi); + __atomic_sub_fetch(&PJ.left, hi - lo, __ATOMIC_ACQ_REL); + } +} + +static int par_pending(void) { return __atomic_load_n(&PJ.seq, __ATOMIC_ACQUIRE) & 1; } + +static void par_help(void) { + uint32_t s = __atomic_load_n(&PJ.seq, __ATOMIC_ACQUIRE); + if (!(s & 1)) return; + __atomic_add_fetch(&PJ.in, 1, __ATOMIC_SEQ_CST); + if (__atomic_load_n(&PJ.seq, __ATOMIC_SEQ_CST) == s) par_slices(); + __atomic_sub_fetch(&PJ.in, 1, __ATOMIC_SEQ_CST); +} + +void vk_exec_par(void (*fn)(void *, int, int), void *arg, int n, int grain) { + if (grain < 1) grain = 1; + if (RW.n <= 0 || n <= grain || Q.drained != Q.out) { + if (n > 0) fn(arg, 0, n); + return; + } + plat_mutex_lock(&pj_lock); + PJ.fn = fn, PJ.arg = arg, PJ.n = n, PJ.grain = grain; + __atomic_store_n(&PJ.next, 0, __ATOMIC_RELAXED); + __atomic_store_n(&PJ.left, n, __ATOMIC_RELAXED); + __atomic_add_fetch(&PJ.seq, 1, __ATOMIC_SEQ_CST); // odd: running + for (int k = 0; k < RW.n; k++) + if (__atomic_load_n(&RW.w[k]->sleeping, __ATOMIC_SEQ_CST)) plat_event_set(&RW.w[k]->wake); + par_slices(); + while (__atomic_load_n(&PJ.left, __ATOMIC_ACQUIRE) > 0) plat_pause(); + __atomic_add_fetch(&PJ.seq, 1, __ATOMIC_SEQ_CST); // even: ended + while (__atomic_load_n(&PJ.in, __ATOMIC_SEQ_CST)) plat_pause(); + plat_mutex_unlock(&pj_lock); +} + static void rw_main(void *arg) { RwWorker *w = arg; plat_thread_priority(PLAT_PRIO_HIGHEST); // as the recording thread (rec_claim) perf_lock_timing_here(); // NX2NAVI_LOCK_TIME, as on the recording thread vk_exec_draw_ctx_use(w->dc); for (;;) { + par_help(); Chunk *c = chunk_take(); if (c) { chunk_record(c, w->idx); @@ -1892,13 +1946,16 @@ static void rw_main(void *arg) { } // spins about 100 us after a chunk (the next group of the frame is usually closer), then sleeps uint64_t t0 = kern_now_ns(); - for (unsigned i = 0; __atomic_load_n(&Q.take, __ATOMIC_SEQ_CST) >= __atomic_load_n(&Q.put, __ATOMIC_SEQ_CST); i++) { + for (unsigned i = 0; __atomic_load_n(&Q.take, __ATOMIC_SEQ_CST) >= __atomic_load_n(&Q.put, __ATOMIC_SEQ_CST); + i++) { + if (par_pending()) break; if ((i & 63) || kern_now_ns() - t0 < 100000) { plat_pause(); continue; } __atomic_store_n(&w->sleeping, 1, __ATOMIC_SEQ_CST); - if (__atomic_load_n(&Q.take, __ATOMIC_SEQ_CST) >= __atomic_load_n(&Q.put, __ATOMIC_SEQ_CST)) + if (__atomic_load_n(&Q.take, __ATOMIC_SEQ_CST) >= __atomic_load_n(&Q.put, __ATOMIC_SEQ_CST) && + !par_pending()) plat_event_wait_ns(&w->wake, 2000000); __atomic_store_n(&w->sleeping, 0, __ATOMIC_RELAXED); t0 = kern_now_ns(); diff --git a/runtime/vk_usnap.c b/runtime/vk_usnap.c index c9a6ff9c..b02b1655 100644 --- a/runtime/vk_usnap.c +++ b/runtime/vk_usnap.c @@ -264,15 +264,39 @@ static void summary(void) { U.subs = U.off_subs = U.regions = U.bytes = U.copy_ns = U.hits = U.drops = U.inflight = U.refused = 0; } -// The bytes of regions reg[0..nreg) (total bytes in all) into a's device copy, through its host copy with DLSS. -static void copy_regions(Arena *a, const Reg *reg, int nreg, uint64_t total) { +// copy_regions's work, sliced for the replay workers (vk_exec_par): regions of the game's memory into the arena, then +// the host copy into the device copy in COPY_SLICE pieces. +#define COPY_SLICE (64u << 10) +typedef struct { uint8_t *dst, *map; const Reg *reg; uint64_t total; } CopyJob; +static void copy_some(void *arg, int lo, int hi) { + const CopyJob *j = arg; + for (int i = lo; i < hi; i++) + memcpy(j->dst + j->reg[i].off, (const void *)(uintptr_t)j->reg[i].lo, j->reg[i].hi - j->reg[i].lo); +} +static void copy_down(void *arg, int lo, int hi) { + const CopyJob *j = arg; + uint64_t a = (uint64_t)lo * COPY_SLICE, b = (uint64_t)hi * COPY_SLICE; + if (b > j->total) b = j->total; + memcpy(j->map + a, j->dst + a, b - a); +} + +// The bytes of regions reg[0..nreg) (total bytes in all) into a's device copy, through its host copy with DLSS. With +// par (the replay thread at a submission's start), split over the replay workers: about 700 regions and 0.7 MiB in +// Hyrule Field, half a millisecond of the submitting thread's time on one thread. +static void copy_regions(Arena *a, const Reg *reg, int nreg, uint64_t total, int par) { uint8_t *dst = a->map; if (vk_dlaa_available()) { if (!a->cpu && !(a->cpu = malloc(a->size))) rt_fatal("vk_usnap: out of memory"); dst = a->cpu; } - for (int i = 0; i < nreg; i++) memcpy(dst + reg[i].off, (const void *)(uintptr_t)reg[i].lo, reg[i].hi - reg[i].lo); - if (dst != a->map) memcpy(a->map, dst, total); // one sequential write into the device copy + CopyJob j = {dst, a->map, reg, total}; + if (par) vk_exec_par(copy_some, &j, nreg, 48); + else copy_some(&j, 0, nreg); + if (dst != a->map) { // sequential writes into the device copy + int slices = (int)((total + COPY_SLICE - 1) / COPY_SLICE); + if (par) vk_exec_par(copy_down, &j, slices, 2); + else copy_down(&j, 0, slices); + } vmaFlushAllocation(vk_ctx()->vma, a->alloc, 0, total); // a no-op on coherent memory } @@ -307,7 +331,7 @@ void vk_usnap_take(const uint64_t *handles, int n) { t->total += (hi - lo + ALIGN - 1) & ~(uint64_t)(ALIGN - 1); } plat_mutex_unlock(&gather_lock); - if (t->nreg && (t->arena = arena_get(t->total))) copy_regions(t->arena, t->reg, t->nreg, t->total); + if (t->nreg && (t->arena = arena_get(t->total))) copy_regions(t->arena, t->reg, t->nreg, t->total, 0); t->ns = kern_now_ns() - t0; plat_mutex_lock(&tq_lock); if (tq_tail) tq_tail->next = t; @@ -393,7 +417,7 @@ void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence) { plat_mutex_unlock(&gather_lock); if (!U.nreg) return; if (!(U.arena = arena_get(total))) return; - copy_regions(U.arena, U.reg, U.nreg, total); + copy_regions(U.arena, U.reg, U.nreg, total, 1); // the replay thread at the submission's start U.on = 1; U.subs++, U.regions += (uint64_t)U.nreg, U.bytes += total, U.copy_ns += kern_now_ns() - t0; } From 432b4e4b1c42bd0bf9f65b660230de97698fdfd8 Mon Sep 17 00:00:00 2001 From: epicmeme546 <178368684+epicmeme546@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:10:27 -0500 Subject: [PATCH 3/3] Turn the replay workers on by default: three of them record a frame's large draw groups beside the submitting thread. On an RX 7900 XT with a Ryzen 7 5800X3D, Hyrule Field standing went from 41.2 to 50.4 fps in one batch, and the pictures matched those with the workers off there and in Kokiri Forest, Kakariko, Goron City, a graveyard house and Gerudo Valley. replay_workers = 0 in nx2navi.ini turns them off. Co-Authored-By: Claude Opus 5.5 --- runtime/cfg.c | 2 +- runtime/vk_exec.c | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/runtime/cfg.c b/runtime/cfg.c index 36e3263e..96d8867f 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -249,7 +249,7 @@ static const CfgMeta metas[] = { {"COOP_ACCEPT", "ask", NULL, LIVE}, // Advanced: scheduling {"ASYNC_PREEMPT", "1", "0|1|2", RESTART}, {"REPLAY_ASYNC", "0", "0|1", RESTART}, - {"REPLAY_WORKERS", "0", "0..16", RESTART}, {"REPLAY_PIPE", "0", "0..4", RESTART}, + {"REPLAY_WORKERS", "3", "0..16", RESTART}, {"REPLAY_PIPE", "0", "0..4", RESTART}, {"FREE_CORES", "0", "0|1", RESTART}, {"TIMER_1MS", "1", "0|1", RESTART}, // dynamic resolution (dynres.c): the scene follows the GPU time toward DYN_RES_TARGET milliseconds {"DYN_RES", "0", "0|1", LIVE}, {"DYN_RES_TARGET", "16.7", "1..100", LIVE}, {"DYN_RES_STEP", "45", "8..600", LIVE}, diff --git a/runtime/vk_exec.c b/runtime/vk_exec.c index c1c846bb..eea5ce3e 100644 --- a/runtime/vk_exec.c +++ b/runtime/vk_exec.c @@ -1760,7 +1760,10 @@ static void op_one(VkExec *ex, const op_hdr *h) { } } -// ---- replay workers (NX2NAVI_REPLAY_WORKERS, default 0: the replay records every op on the submitting thread) +// ---- replay workers (NX2NAVI_REPLAY_WORKERS, default 3; 0: the replay records every op on the submitting thread) +// Default 3: on an RX 7900 XT with a Ryzen 7 5800X3D, Hyrule Field standing went from 41.2 to 50.4 fps (2026-11-12, one +// batch), with the same pictures there and in Kokiri Forest, Kakariko, Goron City, a graveyard house and Gerudo +// Valley; 5 or 7 workers measured the same as 3. // A submission's draws come in runs between ops that have to stay in the replay's order on its own thread (dispatches, // copies, texture and buffer clears, events, the game's barriers, calls). Hyrule Field, standing (NX2NAVI_REPLAY_RUNS, // 2026-11-11): about 3,500 draws a frame in 82 runs, 96% of them in runs of 16 draws or more and 67% in runs of 256 or