Merge main into the 0.9.1 release: the launcher's animations stopped behind another window, the uniform snapshot's copy split over the replay workers, and the replay workers' default of three with its measurements.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 07:29:41 -05:00
5 changed files with 103 additions and 12 deletions

View File

@@ -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`:

View File

@@ -1996,14 +1996,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)

View File

@@ -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.

View File

@@ -1761,6 +1761,9 @@ static void op_one(VkExec *ex, const op_hdr *h) {
}
// ---- 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
@@ -1879,12 +1882,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 +1949,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();

View File

@@ -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;
}