Files
NX2navi/runtime/boot.c
chrg6 375ecf27e2 Wait for the whole boot prewarm and show its progress on a boot screen.
The game used to start after at most 3 s of prewarm, so after a translator, GPU or driver change it met the rest of
its shaders in play and stalled on each one. It now waits for all of it: a warm start is ready in about half a second
and shows nothing, while a cold one shows a "Preparing shaders" screen in the menu's colours and the game's fonts, with
the shader and then the pipeline count and a bar. The shader and pipeline prewarm report their numbers every 100 ms.
The present path takes a frame without a game image for the screen. The RomFS opens on the first host-side read, so
the screen's fonts load before the guest's first file access. NX2NAVI_PREWARM_WAIT_MS now defaults to 0 (no cap). With
no progress for 60 s the game starts anyway and the prewarm finishes in the background.

Measured with an emptied cache: 2,988 shaders translated and 2,399 pipelines built in 9.4 s behind the screen, then
0 ms shader and pipeline waits in Kokiri Forest. Warm: the game starts 0.47 s after launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 09:26:51 -05:00

426 lines
21 KiB
C

// Contains code Copyright (c) 2026 MacNativeGames contributors, Copyright (c) 2026 Peiikai (MIT; see
// THIRD_PARTY_NOTICES.md).
// The boot driver: the guest main thread runs the game's own nn::init::Start
// with three host callbacks, the second of which runs DT_INIT of main, subsdk0 and sdk; Start then calls nnMain.
// --boot checks two checkpoints on the way and ends at the first nvnBootstrapLoader call, at the return address of
// each in main (1.1.0, 1.2.1, 1.4.3):
// SetFocusHandlingMode main+0xeb8c14 (1.2.1: +0xe7a954, 1.4.3: +0xbdb48c) in nnMain, every module's constructor
// flag reads 2
// nvnBootstrapLoader main+0xebcdec (1.2.1: +0xe7eb2c, 1.4.3: +0xbe0360) in the graphics bring-up; logs the
// run statistics, exits 0
// With --seconds S the run continues past that call; rt_frame_presented ends it once the world clock passed S.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cfg.h"
#include "cheats.h"
#include "fs.h"
#include "kernel.h"
#include "ipc.h"
#include "gfx.h"
#include "mods.h"
#include "nvn_api.h"
#include "platform/platform.h"
#include "ui/menu.h"
#include "vk.h"
#include "platform/platform.h"
#define SYM_INIT_START "_ZN2nn4init5StartEmmPFvvES2_S2_"
// main: return addresses of nnMain's SetFocusHandlingMode call (from the analysis of each build's main)
// and of the first nvnBootstrapLoader call, per game version.
#define CHECKPOINT_RET (RT_V143 ? 0xbdb48cull : RT_V110 ? 0xeb8c14ull : 0xe7a954ull)
#define NVN_RET (RT_V143 ? 0xbe0360ull : RT_V110 ? 0xebcdecull : 0xe7eb2cull)
#define CTOR_DONE 2 // a module's constructor flag after its DT_INIT ran the init_array
// The init_array sizes (1.1.0 and 1.2.1: counted in the modules; 1.4.3: DT_INIT_ARRAYSZ).
// The three tables carry the same rows, so cb_module_inits walks them by the size of one.
typedef struct { const char *name; uint64_t entries; } InitCount;
static const InitCount inits_110[] = {{"main", 10668}, {"subsdk0", 45}, {"sdk", 116}};
static const InitCount inits_121[] = {{"main", 10673}, {"subsdk0", 45}, {"sdk", 116}};
static const InitCount inits_143[] = {{"main", 10717}, {"subsdk0", 45}, {"sdk", 83}};
#define expected_inits (RT_V143 ? inits_143 : RT_V110 ? inits_110 : inits_121)
static volatile int32_t inits_done, checkpoint_seen;
uint64_t rt_guest_start_ns;
static const RtLoaded *module_named(const char *name) {
for (int i = 0; i < ldr_count(); i++)
if (!strcmp(ldr_module(i)->name, name)) return ldr_module(i);
rt_fatal("boot: module %s is not loaded", name);
}
// The constructor flag is a NX2navi symbol the loader resolves; a title without it (or with a different
// name) reports "done" rather than dereferencing a null address.
static uint32_t ctor_state(const RtLoaded *m) { return m->ctor_flag ? LD32(m->ctor_flag) : CTOR_DONE; }
static const char *caller(const Cpu *c, char *buf, int n) { return ldr_describe(c->x[30] - 4, buf, n); }
// main.npdm META: the main thread's priority (+0xE), core (+0xF) and stack size (+0x1C). kernel.c builds the main
// thread with the 1.2.1 values; a different npdm means a different game build.
static void check_npdm(void) {
size_t n;
uint8_t *npdm = ldr_read_verified(rt_config()->exefs, "main.npdm", &n);
if (n < 0x80 || memcmp(npdm, "META", 4)) rt_fatal("boot: main.npdm has no META header");
uint32_t stack;
memcpy(&stack, npdm + 0x1C, 4);
int prio = npdm[0xE], core = npdm[0xF];
free(npdm);
if (prio != 44 || core != 0 || stack != 0x100000)
rt_fatal("boot: main.npdm asks for priority %d, core %d, stack %#x; kernel.c builds 44, 0, 0x100000", prio,
core, stack);
}
// ---- the three callbacks of nn::init::Start (rtld's job on hardware)
static void cb_loader_ready(Cpu *c) {
(void)c;
rt_log("boot: rtld callback: modules linked");
}
// DT_INIT registers the module with musl (__nnmusl_init_dso) and runs its init_array; the constructor flag reads 2
// afterwards.
static void cb_module_inits(Cpu *c) {
kern_seed_tls(); // the SDK clears the thread-local region during init; put the TLS back where it reads it
{ // where the sdk's lock thunks actually land once the loader has resolved them
static const unsigned long long off[4] = {0xe8fd20ull, 0xe8dff8ull, 0xe8f538ull, 0xe91200ull};
const unsigned char *sdkimg = (const unsigned char *)(uintptr_t)0x7005200000ull;
for (int k = 0; k < 4; k++)
rt_logf("boot: sdk GOT[%#llx] = %#llx\n", off[k],
*(const unsigned long long *)(const void *)(sdkimg + off[k]));
}
if (plat_cas32(&inits_done, 0, 0)) rt_fatal("boot: module constructors requested twice");
for (unsigned k = 0; k < sizeof inits_121 / sizeof *inits_121; k++) {
const RtLoaded *m = module_named(expected_inits[k].name);
uint64_t entries = m->init_array_size / 8;
if (!m->init) rt_fatal("boot: %s has no DT_INIT", m->name);
rt_log("boot: ENTER %s DT_INIT (%llu init_array entries)", m->name, (unsigned long long)entries);
rt_call(c, m->init, 0);
uint32_t state = ctor_state(m);
rt_logf("boot: DT_INIT %s state=%u (%llu)\n", m->name, state, (unsigned long long)entries);
if (state != CTOR_DONE) rt_fatal("boot: %s DT_INIT left its constructor flag at %u, not 2", m->name, state);
if (entries != expected_inits[k].entries)
rt_logf("boot: %s has %llu init_array entries (this title, not the %llu of the expected version)\n",
m->name, (unsigned long long)entries, (unsigned long long)expected_inits[k].entries);
}
plat_xchg32(&inits_done, 1);
}
static void cb_module_finis(Cpu *c) {
char lr[128];
rt_fatal("boot: the game asked for module finalizers (from %s): the guest exited", caller(c, lr, sizeof lr));
}
// ---- checkpoints (host.h)
static void stats_line(void) {
RtStats s = rt_stats();
rt_logf("boot: passed (%llu SVCs, %llu threads, %llu host calls)\n", (unsigned long long)s.svcs,
(unsigned long long)s.threads, (unsigned long long)s.host_calls);
}
void rt_boot_event(const char *what, Cpu *c, const char *detail) {
char at[128];
const RtLoaded *main_mod = module_named("main");
uint64_t ret = c->x[30];
caller(c, at, sizeof at);
if (!strcmp(what, "checkpoint")) {
if (ret != main_mod->base + CHECKPOINT_RET)
rt_fatal("boot: checkpoint %s reached from %s, expected main+%#llx", detail, at,
(unsigned long long)CHECKPOINT_RET);
if (!plat_cas32(&inits_done, 0, 0)) rt_fatal("boot: checkpoint %s before DT_INIT", detail);
for (int i = 0; i < ldr_count(); i++)
if (ctor_state(ldr_module(i)) != CTOR_DONE)
rt_fatal("boot: checkpoint %s: %s constructor flag reads %u", detail, ldr_module(i)->name,
ctor_state(ldr_module(i)));
plat_xchg32(&checkpoint_seen, 1);
rt_logf("boot: checkpoint %s from main+%#llx\n", detail, (unsigned long long)(ret - main_mod->base));
return;
}
if (!strcmp(what, "nvn")) {
if (ret != main_mod->base + NVN_RET)
rt_fatal("boot: first NVN call %s(\"%s\") from %s, expected main+%#llx", "nvnBootstrapLoader", detail, at,
(unsigned long long)NVN_RET);
if (!plat_cas32(&checkpoint_seen, 0, 0))
rt_fatal("boot: first NVN call before the SetFocusHandlingMode checkpoint");
rt_logf("boot: first NVN call nvnBootstrapLoader(\"%s\") from main+%#llx\n", detail,
(unsigned long long)(ret - main_mod->base));
if (rt_config()->seconds) return; // --seconds: the run goes on into the world
if (rt_trace & RT_TRACE_HOSTCALL) hostfn_report();
stats_line();
fflush(stdout);
fflush(stderr);
plat_exit(0); // other guest threads are still running; nothing of theirs needs an orderly end
}
rt_fatal("boot: unknown boot event %s (%s) from %s", what, detail ? detail : "", at);
}
// ---- --seconds S
// A PNG with stored (uncompressed) deflate blocks: no compression library, and the shot is only read by tools.
// rt_write_png (declared in vk.h) is shared with the NX2NAVI_DUMP frame dump (vk_dump.c).
static uint32_t crc32_update(uint32_t crc, const uint8_t *p, size_t n) {
static uint32_t table[256];
if (!table[1])
for (uint32_t i = 0; i < 256; i++) {
uint32_t c = i;
for (int k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[i] = c;
}
for (size_t i = 0; i < n; i++) crc = table[(crc ^ p[i]) & 0xFF] ^ (crc >> 8);
return crc;
}
static void png_chunk(FILE *f, const char *type, const uint8_t *data, uint32_t n) {
uint8_t hdr[8] = {(uint8_t)(n >> 24), (uint8_t)(n >> 16), (uint8_t)(n >> 8), (uint8_t)n};
memcpy(hdr + 4, type, 4);
uint32_t crc = crc32_update(crc32_update(~0u, hdr + 4, 4), data, n) ^ ~0u;
uint8_t tail[4] = {(uint8_t)(crc >> 24), (uint8_t)(crc >> 16), (uint8_t)(crc >> 8), (uint8_t)crc};
fwrite(hdr, 1, 8, f);
if (n) fwrite(data, 1, n, f);
fwrite(tail, 1, 4, f);
}
void rt_write_png(const char *path, const uint8_t *rgba, uint32_t w, uint32_t h) {
uint64_t row = 1 + 4ull * w, raw_n = row * h;
uint8_t *raw = malloc(raw_n);
if (!raw) rt_fatal("boot: out of memory for the shot");
for (uint32_t y = 0; y < h; y++) {
raw[y * row] = 0; // filter: none
memcpy(raw + y * row + 1, rgba + 4ull * w * y, 4ull * w);
}
uint64_t blocks = (raw_n + 65534) / 65535;
uint8_t *z = malloc(2 + raw_n + 5 * blocks + 4);
if (!z) rt_fatal("boot: out of memory for the shot");
uint64_t zn = 0;
z[zn++] = 0x78, z[zn++] = 0x01;
uint32_t a = 1, b = 0;
for (uint64_t off = 0; off < raw_n; off += 65535) {
uint32_t k = raw_n - off > 65535 ? 65535 : (uint32_t)(raw_n - off);
z[zn++] = off + k >= raw_n;
z[zn++] = (uint8_t)k, z[zn++] = (uint8_t)(k >> 8), z[zn++] = (uint8_t)~k, z[zn++] = (uint8_t)(~k >> 8);
memcpy(z + zn, raw + off, k);
zn += k;
for (uint32_t i = 0; i < k; i++) a = (a + raw[off + i]) % 65521, b = (b + a) % 65521;
}
uint32_t ad = b << 16 | a;
z[zn++] = (uint8_t)(ad >> 24), z[zn++] = (uint8_t)(ad >> 16), z[zn++] = (uint8_t)(ad >> 8), z[zn++] = (uint8_t)ad;
FILE *f = fopen(path, "wb");
if (!f) rt_fatal("boot: cannot write %s", path);
static const uint8_t sig[8] = {0x89, 'P', 'N', 'G', 13, 10, 26, 10};
uint8_t ihdr[13] = {(uint8_t)(w >> 24), (uint8_t)(w >> 16), (uint8_t)(w >> 8), (uint8_t)w,
(uint8_t)(h >> 24), (uint8_t)(h >> 16), (uint8_t)(h >> 8), (uint8_t)h, 8, 6, 0, 0, 0};
fwrite(sig, 1, 8, f);
png_chunk(f, "IHDR", ihdr, 13);
png_chunk(f, "IDAT", z, (uint32_t)zn);
png_chunk(f, "IEND", NULL, 0);
if (fclose(f)) rt_fatal("boot: cannot write %s", path);
free(z);
free(raw);
}
static void save_shot(const tex_t *t, const char *path) {
int fmt = t->d.format;
int bgra = fmt == NVN_FORMAT_BGRA8 || fmt == NVN_FORMAT_BGRA8_SRGB;
if (!bgra && fmt != NVN_FORMAT_RGBA8 && fmt != NVN_FORMAT_RGBA8_SRGB)
rt_fatal("boot: --shot supports 8-bit RGBA and BGRA targets, the presented texture has NVN format %#x", fmt);
uint32_t w = t->d.width, h = t->d.height;
uint8_t *px = malloc(4ull * w * h);
if (!px) rt_fatal("boot: out of memory for the shot");
view_t view;
memset(&view, 0, sizeof view);
NVNcopyRegion r = {.width = (int)w, .height = (int)h, .depth = 1};
if (gfx_replay_async()) gfx_backend()->finish(); // the presented frame may still be queued for the replay
gfx_backend()->read_texels(t, &view, &r, px, 4ull * w, 4ull * w * h);
if (bgra)
for (uint64_t i = 0; i < 4ull * w * h; i += 4) {
uint8_t x = px[i];
px[i] = px[i + 2];
px[i + 2] = x;
}
rt_write_png(path, px, w, h);
free(px);
rt_logf("boot: shot %s (%ux%u)\n", path, w, h);
}
// NX2NAVI_SHOTS=lo-hi:step [NX2NAVI_SHOTS_DIR=dir]: every step-th present in [lo, hi] saved as <dir>/f<n>.png (n the
// present), box-filtered to 480x270; NX2NAVI_SHOTS=w<lo>-<hi>:<step> takes them by world-clock seconds instead, the first
// present at or after each step (full size with NX2NAVI_SHOTS_FULL=1), for finding short glitches. Pipeline builds stay asynchronous, so the
// pictures show what a player sees.
static void series_shot(const tex_t *t, uint64_t n) {
static int parsed;
static unsigned long long lo, hi, step, next;
static const char *dir;
static int full, world;
if (!parsed) {
full = cfg_get("NX2NAVI_SHOTS_FULL") != NULL;
const char *e = cfg_get("NX2NAVI_SHOTS");
double wl, wh, ws;
if (e && e[0] == 'w' && sscanf(e + 1, "%lf-%lf:%lf", &wl, &wh, &ws) == 3 && ws > 0) // world-clock seconds
world = 1, lo = (unsigned long long)(wl * 1e6), hi = (unsigned long long)(wh * 1e6),
step = (unsigned long long)(ws * 1e6), next = lo;
else if (!e || sscanf(e, "%llu-%llu:%llu", &lo, &hi, &step) != 3 || !step) hi = 0;
dir = cfg_get("NX2NAVI_SHOTS_DIR") ? cfg_get("NX2NAVI_SHOTS_DIR") : ".";
if (hi) plat_file_mkdir_p(dir);
parsed = 1;
}
if (world) { // the first present at or after each step of the world clock
int64_t w = frame_step_world_us();
if (w < 0 || (unsigned long long)w < next || next > hi) return;
while (next <= (unsigned long long)w) next += step;
} else if (n < lo || n > hi || (n - lo) % step) {
return;
}
int fmt = t->d.format;
int bgra = fmt == NVN_FORMAT_BGRA8 || fmt == NVN_FORMAT_BGRA8_SRGB;
if (!bgra && fmt != NVN_FORMAT_RGBA8 && fmt != NVN_FORMAT_RGBA8_SRGB) return;
uint32_t w = t->d.width, h = t->d.height, ow = full ? w : 480, oh = full ? h : 270;
uint8_t *px = malloc(4ull * w * h), *out = malloc(4ull * ow * oh);
if (!px || !out) rt_fatal("boot: out of memory for a NX2NAVI_SHOTS frame");
view_t view;
memset(&view, 0, sizeof view);
NVNcopyRegion r = {.width = (int)w, .height = (int)h, .depth = 1};
if (gfx_replay_async()) gfx_backend()->finish(); // the presented frame may still be queued for the replay
gfx_backend()->read_texels(t, &view, &r, px, 4ull * w, 4ull * w * h);
for (uint32_t y = 0; y < oh; y++)
for (uint32_t x = 0; x < ow; x++) {
uint32_t x0 = x * w / ow, x1 = (x + 1) * w / ow, y0 = y * h / oh, y1 = (y + 1) * h / oh;
if (x1 <= x0) x1 = x0 + 1;
if (y1 <= y0) y1 = y0 + 1;
uint32_t sum[4] = {0}, k = 0;
for (uint32_t sy = y0; sy < y1; sy++)
for (uint32_t sx = x0; sx < x1; sx++, k++)
for (int c = 0; c < 4; c++) sum[c] += px[4ull * (sy * (uint64_t)w + sx) + (uint32_t)c];
uint8_t *o = out + 4ull * (y * ow + x);
for (int c = 0; c < 4; c++) o[c] = (uint8_t)(sum[bgra && c != 3 ? 2 - c : c] / k);
o[3] = 255;
}
char path[PLAT_PATH_MAX];
snprintf(path, sizeof path, "%s/f%05llu.png", dir, (unsigned long long)n);
rt_write_png(path, out, ow, oh);
free(out);
free(px);
}
// A --seconds run ends at the first present after the world clock (game time since the save's first frame,
// frame_step_world_us) passed S seconds, so a run covers the same game time at any frame rate; any other frame is
// counted by the back end only.
void rt_frame_presented(const tex_t *t, uint64_t n) {
const RtConfig *cfg = rt_config();
series_shot(t, n);
int64_t w = cfg->seconds ? frame_step_world_us() : -1;
if (w < 0 || (double)w < cfg->seconds * 1e6) return;
if (cfg->shot) save_shot(t, cfg->shot);
GfxStats g = gfx_stats();
rt_logf("boot: frames %llu presented (nvn calls %llu, distinct %llu, unimplemented %llu, validation errors %llu, "
"shader failures %llu, draws skipped %llu)\n", (unsigned long long)g.frames,
(unsigned long long)g.nvn_calls, (unsigned long long)g.nvn_distinct, (unsigned long long)g.unimplemented,
(unsigned long long)g.validation_errors, (unsigned long long)g.shader_failures,
(unsigned long long)g.draws_skipped);
stats_line();
fflush(stdout);
fflush(stderr);
fs_save_flush();
vk_pipeline_save(); // no-op without the Vulkan back end
prof_write();
fflush(stderr);
plat_exit(0);
}
// ---- steps 9-14
static uint64_t cb_addr[3];
static void guest_main(Cpu *c) {
uint32_t handle = kern_main_thread(&c);
uint64_t start = ldr_symbol(SYM_INIT_START);
if (!start) rt_fatal("boot: %s is not exported", SYM_INIT_START);
char d[128];
rt_log("boot: ENTER nn::init::Start at %s, main thread %#x", ldr_describe(start, d, sizeof d), handle);
// nn::init::Start(main thread handle, arguments region, loader_ready, module_inits, module_finis)
rt_call(c, start, 5, (uint64_t)handle, (uint64_t)0, cb_addr[0], cb_addr[1], cb_addr[2]);
rt_fatal("boot: nn::init::Start returned");
}
static void prewarm_main(void *arg) {
(void)arg;
vk_pipeline_prewarm();
const char *prep = cfg_get("NX2NAVI_TEST_PREPARE_SHADERS"); // Task 2.3 test hook; Task 2.6 builds the preparation
if (prep && !strcmp(prep, "1")) {
int cpus = (int)plat_cpu_usable();
gfx_shader_prepare_recorded(cpus, cpus / 2 > 16 ? 16 : cpus / 2 < 1 ? 1 : cpus / 2);
}
return;
}
// rt_boot's wait for the prewarm, polled every 50 ms. A warm start is done in about half a second and shows nothing.
// When the prewarm translates shaders (a new translator, GPU or driver, or an emptied cache) or runs past
// BOOT_SCREEN_MS, the window shows the boot screen with its progress at 10 frames a second, and a black frame once it is
// done. cap_ms (NX2NAVI_PREWARM_WAIT_MS) bounds the wait, 0 none. Without progress for BOOT_STALL_MS (a translator or
// driver that hangs) the guest starts anyway; the prewarm then goes on in the background.
#define BOOT_SCREEN_MS 1000
#define BOOT_STALL_MS 60000
static void boot_wait(PlatThread *warm, uint64_t cap_ms) {
uint64_t t0 = plat_now_ms(), moved = t0, shown = 0;
VkPrewarmProgress p, last = {.phase = -1};
int screen = 0;
while (!plat_thread_join_ns(warm, 50000000ull)) {
uint64_t now = plat_now_ms();
vk_pipeline_prewarm_progress(&p);
if (p.phase != last.phase || p.sh_done != last.sh_done || p.pipe_done != last.pipe_done) last = p, moved = now;
if ((cap_ms && now - t0 >= cap_ms) || now - moved >= BOOT_STALL_MS) {
plat_thread_detach(warm);
rt_log("boot: the prewarm goes on in the background after %llu ms (%s)", (unsigned long long)(now - t0),
cap_ms && now - t0 >= cap_ms ? "NX2NAVI_PREWARM_WAIT_MS" : "no progress for 60 s");
break;
}
if (!screen && (p.sh_translated > 0 || now - t0 >= BOOT_SCREEN_MS)) {
screen = 1;
rt_log("boot: preparing shaders on the boot screen (%lld of %lld shaders done, %lld translated)",
(long long)p.sh_done, (long long)p.sh_total, (long long)p.sh_translated);
}
if (screen && now - shown >= 100) {
int pipes = p.phase >= 2;
menu_boot_set(p.phase, pipes ? p.pipe_done : p.sh_done, pipes ? p.pipe_total : p.sh_total);
gfx_present_boot();
shown = now;
}
}
if (screen) { // the window stays black, not on the last count, until the game's first frame
menu_boot_set(-1, 0, 0);
gfx_present_boot();
}
}
void rt_boot(void) {
check_npdm();
if (!rt_config()->shot) vk_pipeline_async_enable(); // a --shot image must not depend on build timing
// Shaders and pipelines recorded in earlier runs are made ready while the modules load; the guest waits for them.
PlatThread *warm = plat_thread_new(prewarm_main, NULL, "prewarm");
ldr_load(rt_config()->exefs);
rt_patches_init();
mods_exefs_switch(); // after the patch sets' settings: an enabled mod's ExeFS patches on
frame_step_init();
res_init();
wide_init(); // Ocarina's widescreen sets (recomp/patches-v111.txt)
cheats_init(); // after the patch sets: a cheat's set follows its setting
cb_addr[0] = hostfn_register("<rtld loader_ready>", cb_loader_ready);
cb_addr[1] = hostfn_register("<rtld module_inits>", cb_module_inits);
cb_addr[2] = hostfn_register("<rtld module_finis>", cb_module_finis);
ipc_applet_event_init();
hostfn_seal();
uint64_t held = plat_now_ms();
// The guest waits for all of the prewarm (boot_wait): a warm start is ready in about half a second; a cold one (a new
// translator, GPU or driver) translated 2,987 shaders in about 11 s and then builds the recorded pipelines, with the
// boot screen showing its progress. Started early instead, the game would meet those shaders in play and wait for
// each one there. NX2NAVI_PREWARM_WAIT_MS caps the wait (0, the default: none); past it the prewarm goes on in the
// background. A --shot image waits for all of it without the screen, so that it does not depend on build timing.
const char *pw = cfg_get("NX2NAVI_PREWARM_WAIT_MS");
uint64_t wait_ms = pw && *pw ? strtoull(pw, NULL, 10) : 0;
if (warm && rt_config()->shot) plat_thread_join(warm);
else if (warm) boot_wait(warm, wait_ms);
else vk_pipeline_prewarm();
held = plat_now_ms() - held;
rt_log("boot: guest starts %llu ms after process start (%llu ms of it waiting for the prewarm)",
(unsigned long long)(plat_process_uptime_ns() / 1000000), (unsigned long long)held);
rt_guest_start_ns = kern_now_ns();
kern_run_on_guest_main(guest_main);
rt_fatal("boot: the guest main thread ended");
}