From cd0917e18d34ec6ec6734332a24b9d82dfbcfa49 Mon Sep 17 00:00:00 2001 From: Wellington Dias Date: Sun, 1 Nov 2026 02:08:15 -0300 Subject: [PATCH 01/39] Add a dead zone slider per analog stick to the Controls tab, carrying an older stick_deadzone line over to both. --- docs/controls.md | 4 ++-- runtime/cfg.c | 30 +++++++++++++++++++++++++----- runtime/cfg.h | 3 +++ runtime/cfg_probe.c | 33 +++++++++++++++++++++++++++++++++ runtime/input.c | 29 +++++++++++++++++++---------- runtime/ui/menu.cpp | 4 +++- runtime/ui_text.c | 14 ++++++++++---- runtime/ui_text.h | 3 ++- totk.ini.example | 8 +++++--- 9 files changed, 102 insertions(+), 26 deletions(-) diff --git a/docs/controls.md b/docs/controls.md index 3cd4082c..0e1f4ee5 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -82,7 +82,7 @@ Mouse motion (raw input) becomes right-stick deflection. Each presented frame ta `TOTK_MOUSE_SENS` (0 to 10, default 1; higher turns faster, 0 turns the mouse camera and the cursor capture off). While the window is the foreground window the cursor is hidden and confined to the client area. It is released on focus loss, minimise, while F11 changes the window, during a size or move loop and while a text entry is open. Esc releases it by hand: the cursor shows, the mouse camera and mouse buttons stop, and the window can be moved, minimised or closed. A mouse click inside the client area captures it again; that click does not reach the game. Activating the window by a click on its title bar or border does not capture, so it can be dragged or closed right away; Alt+Tab or a click in the client area captures as before. `TOTK_INPUT=none` disables the capture too. -`TOTK_STICK_DEADZONE` (0 to 50, default 8) is the gamepad stick dead zone in percent of full deflection, the same for both sticks. It is round: a stick inside the circle reads centred, and the rest of its travel is scaled to start at 0 at the edge, so full deflection still reaches full. Raise it for a stick that drifts. The game's own dead zone applies after it. +`TOTK_LSTICK_DEADZONE` and `TOTK_RSTICK_DEADZONE` (0 to 50, default 8; the Analog sticks section of the Controls tab) are the gamepad stick dead zones in percent of full deflection, one per stick. Each is round: a stick inside the circle reads centred, and the rest of its travel is scaled to start at 0 at the edge, so full deflection still reaches full. Raise one for a stick that drifts. The game's own dead zone applies after it. A `stick_deadzone` line in an older `totk.ini` is carried over to both keys at the next start and removed; `TOTK_STICK_DEADZONE` set in the environment still applies to both sticks. The stick direction bits (`StickLLeft` to `StickRDown`) are derived from the sticks at 50% deflection. A gamepad plugged in later is picked up within one second; with none connected the log says `input: no gamepad`. @@ -107,7 +107,7 @@ Bridges that output a ViGEm DualShock 4 (ProCon2Windows, connectyourpro as teste `TOTK_INPUT_RECORD=` (`input_record` in `totk.ini`, or Input recording in the menu) writes the merged input in the same format, one line per button press and per stick segment: events before the world clock starts by tick, the rest timed; a button or stick held when the world clock starts is cut there and goes on as a timed event. The file replays as a script, which makes it the repro to attach to a bug report. -`TOTK_TEST_PAD=,,,` attaches an SDL virtual gamepad at start whose sticks rest at these raw SDL axis values (-32768 to 32767, down is +y), so a drifting or held stick can be tested without a controller: the values go through the gamepad mapping and `TOTK_STICK_DEADZONE` like a real pad's, and `TOTK_INPUT_RECORD` shows what reached the game. +`TOTK_TEST_PAD=,,,` attaches an SDL virtual gamepad at start whose sticks rest at these raw SDL axis values (-32768 to 32767, down is +y), so a drifting or held stick can be tested without a controller: the values go through the gamepad mapping and `TOTK_LSTICK_DEADZONE` / `TOTK_RSTICK_DEADZONE` like a real pad's, and `TOTK_INPUT_RECORD` shows what reached the game. ## RenderDoc capture (blocked) diff --git a/runtime/cfg.c b/runtime/cfg.c index d9c4cd61..637abdd4 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -28,7 +28,8 @@ static PlatMutex set_lock = PLAT_MUTEX_INIT; // cfg_set, cfg_reset, cfg_save static int save_ok = 1; // the last cfg_save_to wrote (or had nothing to write) // Every name the runtime reads (and the patch sets in generated/), without the prefix. A file key outside this list is -// logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line. +// logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line. A renamed key +// stays here while cfg_migrate carries its line over (STICK_DEADZONE, now one key per stick). static const char *const known[] = { "AB", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT", @@ -39,14 +40,14 @@ static const char *const known[] = { "EXEFS", "FEEDBACK", "FIX_C0", "FIX_ZERO", "FLIGHT", "FLIGHT_AT", "FLIGHT_DIR", "FLIGHT_FRAMES", "FLIGHT_SPIKE_MS", "FLIGHT_THUMBS", "FOV", "FOV_PATCH", "FPS60", "FPS_LIMIT", "FREEZE_IDLE", "FREE_CORES", "FULLSCREEN", "GPL", "GPL_DRAW_BUILD", "GPU", "GPU_INDEX", "GPU_NAME", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD", - "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LOCALE", "LOD_GUARD", "MEMORY", "MENU_LANG", "MENU_TEST", + "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MENU_LANG", "MENU_TEST", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MOTION", "MOUSE_SENS", "MOVIE_HOST", "MOVIE_PACE", "ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PATCH_OUTPUT", "PATCH_SCENE", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT", "PREWARM_XLATE", "PROBE_MOVIE", "PROBE_MOVIE_SHOT", "PROFILE", "PROFILE_CHAINS", "PROFILE_DIR", "PROFILE_FRAMES", "PROFILE_MAIN", "PROFILE_RENDER", "PUSH_SKIP", "PUSH_STAGE", "RDOC", "RDOC_DIR", "RDOC_DLL", "REALTIME", "REC_END", "REEXEC", "RENDER_SCALE", "REPLAY_ASYNC", "REPLAY_CHECK", "RESOLVE_CHECK", "ROMFS", "ROMFS_INDEX", "ROMFS_MMAP", "ROMFS_MODS", "ROMFS_PATCHES", - "ROMFS_PROOF_DIR", "ROMFS_VERIFY", "RUMBLE", "RUMBLE_CURVE", "RUMBLE_STRENGTH", "RUMBLE_TRIGGERS", "SHADER_CACHE", + "ROMFS_PROOF_DIR", "ROMFS_VERIFY", "RSTICK_DEADZONE", "RUMBLE", "RUMBLE_CURVE", "RUMBLE_STRENGTH", "RUMBLE_TRIGGERS", "SHADER_CACHE", "SHADER_MEMO", "SHARED_FONTS", "SHARPNESS", "SHOTS", "SHOTS_DIR", "SHOTS_FULL", "SPEED_PROBE", "SPIN", "SPIN_HANDOFF_US", "SPLIT_DRAWS", "SPLIT_US", "STICK_DEADZONE", "SWKBD_ANSWER", "SWKBD_TEST", "TEST_PAD", "TEST_PREPARE_SHADERS", "TEX_ALIAS", "TEX_MEMO", "TEX_VRAM_PRESSURE", "TIMER_1MS", "TRACE", "UBO_SNAPSHOT", "UPSCALER", "VK_DYN_A2C", "VK_DYN_VERTEX", "VK_NO_FLOAT64", "VK_PRESENT", "VK_RECORDER", "VK_SSBO_ALIGN", "VK_SUBGROUP", "VK_SUBGROUP_PIN", "VK_VALIDATION", "VOLUME", "VRAM_HEADROOM", "VS_OUTS", @@ -66,7 +67,7 @@ static const struct { const char *key, *section; } sections[] = { {"MENU_LANG", "language"}, {"LANG", "language"}, {"OPERATION_MODE", "language"}, {"VOLUME", "audio"}, {"MOTION", "input"}, {"RUMBLE_STRENGTH", "input"}, {"RUMBLE_CURVE", "input"}, {"RUMBLE_TRIGGERS", "input"}, - {"PAD_LAYOUT", "input"}, {"STICK_DEADZONE", "input"}, {"MOUSE_SENS", "input"}, {"INPUT_RECORD", "input"}, + {"PAD_LAYOUT", "input"}, {"LSTICK_DEADZONE", "input"}, {"RSTICK_DEADZONE", "input"}, {"MOUSE_SENS", "input"}, {"INPUT_RECORD", "input"}, {"BIND_A", "input"}, {"BIND_B", "input"}, {"BIND_X", "input"}, {"BIND_Y", "input"}, {"BIND_LCLICK", "input"}, {"BIND_RCLICK", "input"}, {"BIND_L", "input"}, {"BIND_R", "input"}, {"BIND_ZL", "input"}, {"BIND_ZR", "input"}, {"BIND_PLUS", "input"}, {"BIND_MINUS", "input"}, {"BIND_LEFT", "input"}, {"BIND_UP", "input"}, @@ -114,7 +115,7 @@ static const CfgMeta metas[] = { {"VOLUME", "100", "0..100", LIVE}, {"MOTION", "1", "0|1", LIVE}, {"RUMBLE_STRENGTH", "130", "0..300", LIVE}, {"RUMBLE_CURVE", "0.6", "0.2..1.5", LIVE}, {"RUMBLE_TRIGGERS", "0", "0|1", LIVE}, {"PAD_LAYOUT", "auto", "auto|nintendo|xbox|xbox_prompts", LIVE}, - {"STICK_DEADZONE", "8", "0..50", LIVE}, {"MOUSE_SENS", "1", "0..10", LIVE}, + {"LSTICK_DEADZONE", "8", "0..50", LIVE}, {"RSTICK_DEADZONE", "8", "0..50", LIVE}, {"MOUSE_SENS", "1", "0..10", LIVE}, {"MENU_LANG", "", "en|ru", LIVE}, // Advanced: scheduling {"ASYNC_PREEMPT", "1", "0|1|2", RESTART}, {"REPLAY_ASYNC", "0", "0|1", RESTART}, @@ -640,8 +641,27 @@ static void load_ini(void) { } } +// Renamed keys: the old totk.ini line would otherwise keep its value while nothing reads it. stick_deadzone (one dead +// zone for both sticks, up to 0.2.8) fills lstick_deadzone and rstick_deadzone where they are unset and its line goes. +// The environment variable is left alone: input.c stick_dead_get reads TOTK_STICK_DEADZONE for both sticks. +int cfg_migrate(void) { + static const char *const old_key[] = {"STICK_DEADZONE"}; + static const char *const new_keys[] = {PREFIX "LSTICK_DEADZONE", PREFIX "RSTICK_DEADZONE"}; + const char *old = cfg_get(PREFIX "STICK_DEADZONE"); + if (!old || cfg_from_env(PREFIX "STICK_DEADZONE")) return 0; + char value[64]; + snprintf(value, sizeof value, "%s", old); // cfg_reset drops the entry old points into + int n = 0; + for (size_t i = 0; i < sizeof new_keys / sizeof *new_keys; i++) + if (!cfg_is_set(new_keys[i])) cfg_set(new_keys[i], value), n++; + cfg_reset(old_key, 1); + rt_log("cfg: stick_deadzone = %s carried over to lstick_deadzone and rstick_deadzone (%d set)", value, n); + return n; +} + void cfg_init(void) { load_ini(); + if (cfg_migrate()) cfg_save(); if (profile_of()) rt_log("cfg: device profile Steam Deck: defaults for the keys totk.ini does not set (TOTK_DEVICE_PROFILE=desktop " "turns it off)"); diff --git a/runtime/cfg.h b/runtime/cfg.h index 5f94bc7a..15ff1457 100644 --- a/runtime/cfg.h +++ b/runtime/cfg.h @@ -8,6 +8,9 @@ const char *cfg_get(const char *name); // Reads totk.ini once, from mem_init after the address space is claimed and before any setting is read. Logs the file, its key count, unknown keys and environment overrides. void cfg_init(void); +// Carries the value of a renamed totk.ini key into its new keys in memory (those still unset) and marks the old line +// for removal at the next save; cfg_init calls it and saves. Returns how many keys took a value. Not thread-safe. +int cfg_migrate(void); // Replaces the loaded keys with those of the file at path (tests). 1 when the file was read, 0 when missing. // Not thread-safe: nothing else may call cfg_get meanwhile. diff --git a/runtime/cfg_probe.c b/runtime/cfg_probe.c index ca7449b2..04005a03 100644 --- a/runtime/cfg_probe.c +++ b/runtime/cfg_probe.c @@ -139,6 +139,11 @@ int probe_cfg(void) { "meta: prefix and case are accepted"); m = cfg_meta("RUMBLE_STRENGTH"); CHECK(m && EQ(m->def, "130") && EQ(m->values, "0..300"), "meta: rumble strength 130 in 0..300"); + m = cfg_meta("LSTICK_DEADZONE"); + CHECK(m && EQ(m->def, "8") && EQ(m->values, "0..50") && m->live, "meta: left stick dead zone 8 in 0..50, live"); + m = cfg_meta("RSTICK_DEADZONE"); + CHECK(m && EQ(m->def, "8") && EQ(m->values, "0..50") && m->live, "meta: right stick dead zone 8 in 0..50, live"); + CHECK(cfg_meta("STICK_DEADZONE") == NULL, "meta: the old single stick dead zone key has no row"); m = cfg_meta("PERF_OVERLAY"); CHECK(m && EQ(m->def, "off") && EQ(m->values, "off|fps|full") && m->live, "meta: perf_overlay is live, off by default"); CHECK(cfg_meta("BIND_A") == NULL && cfg_meta("FPS_COUNTER") == NULL && cfg_meta("NO_SUCH") == NULL, @@ -173,6 +178,34 @@ int probe_cfg(void) { CHECK(got && strstr((const char *)got, "\nvolume = 60\n"), "reset then set: the new line is written"); free(got); + // cfg_migrate: an older stick_deadzone line fills the per-stick keys that are unset, then leaves the file + static const char mbase[] = "[input]\n# lstick_deadzone = 8\n# rstick_deadzone = 8\nstick_deadzone = 15\n"; + plat_setenv("TOTK_STICK_DEADZONE", NULL); + plat_setenv("TOTK_LSTICK_DEADZONE", NULL); + plat_setenv("TOTK_RSTICK_DEADZONE", NULL); + CHECK(plat_file_write(path, mbase, sizeof mbase - 1), "write the migrate ini"); + CHECK(cfg_load(path) == 1 && EQ(cfg_get("TOTK_STICK_DEADZONE"), "15"), "load the migrate ini"); + CHECK(cfg_migrate() == 2, "migrate: both per-stick keys take the old value"); + CHECK(cfg_double("TOTK_LSTICK_DEADZONE") == 15 && cfg_double("TOTK_RSTICK_DEADZONE") == 15 && + cfg_get("TOTK_STICK_DEADZONE") == NULL, + "migrate: 15 for both sticks in memory, the old key gone"); + CHECK(cfg_migrate() == 0, "migrate: nothing left to carry over"); + CHECK(cfg_save_to(path) == 1, "migrate: save"); + got = plat_file_read(path, &got_n); + CHECK(got && strstr((const char *)got, "\nlstick_deadzone = 15\n") && strstr((const char *)got, "\nrstick_deadzone = 15\n") && + !strstr((const char *)got, "\nstick_deadzone = 15\n") && strstr((const char *)got, "# lstick_deadzone = 8"), + "migrate: the per-stick lines are in the file, the old line is not, the examples stay"); + if (got && !strstr((const char *)got, "\nlstick_deadzone = 15\n")) fprintf(stderr, "---- saved:\n%s\n----\n", got); + free(got); + static const char mkeep[] = "[input]\nrstick_deadzone = 20\nstick_deadzone = 15\n"; + CHECK(plat_file_write(path, mkeep, sizeof mkeep - 1) && cfg_load(path) == 1, "load the migrate ini with one stick set"); + CHECK(cfg_migrate() == 1 && cfg_double("TOTK_LSTICK_DEADZONE") == 15 && cfg_double("TOTK_RSTICK_DEADZONE") == 20, + "migrate: a per-stick key already set keeps its value, the other takes the old one"); + plat_setenv("TOTK_STICK_DEADZONE", "30"); + CHECK(cfg_load(path) == 1 && cfg_migrate() == 0 && cfg_double("TOTK_STICK_DEADZONE") == 30, + "migrate: the environment variable is left alone (input.c applies it to both sticks)"); + plat_setenv("TOTK_STICK_DEADZONE", NULL); + // an active line with an empty value has no entry in memory; Reset still removes it from the file static const char rempty[] = "[audio]\n# volume = 100\nvolume =\n[input]\nmotion = 0\n"; CHECK(plat_file_write(path, rempty, sizeof rempty - 1) && cfg_load(path) == 1 && cfg_get("TOTK_VOLUME") == NULL, diff --git a/runtime/input.c b/runtime/input.c index 86391739..a9679802 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -263,16 +263,24 @@ static void mouse_camera(int32_t out[2]) { out[1] = held[1]; } -static float stick_dead = -1; // TOTK_STICK_DEADZONE as a fraction of full deflection; live (cfg_version) +// TOTK_LSTICK_DEADZONE and TOTK_RSTICK_DEADZONE as fractions of full deflection; live (cfg_version). The older +// TOTK_STICK_DEADZONE set in the environment applies to both (an ini line is carried over by cfg_migrate instead). +static float stick_dead[2] = {-1, -1}; static unsigned stick_dead_seen; -static float stick_dead_get(void) { +static void stick_dead_get(float out[2]) { + static const char *const keys[2] = {"TOTK_LSTICK_DEADZONE", "TOTK_RSTICK_DEADZONE"}; unsigned ver = cfg_version(); - if (stick_dead >= 0 && ver == stick_dead_seen) return stick_dead; - stick_dead_seen = ver; - double v = cfg_double("TOTK_STICK_DEADZONE"); - stick_dead = (float)(v < 0 ? 0 : v > 50 ? 50 : v) / 100.f; - return stick_dead; + if (stick_dead[0] < 0 || ver != stick_dead_seen) { + stick_dead_seen = ver; + int both = cfg_from_env("TOTK_STICK_DEADZONE"); + for (int i = 0; i < 2; i++) { + double v = cfg_double(both ? "TOTK_STICK_DEADZONE" : keys[i]); + stick_dead[i] = (float)(v < 0 ? 0 : v > 50 ? 50 : v) / 100.f; + } + } + out[0] = stick_dead[0]; + out[1] = stick_dead[1]; } // One gamepad stick through the dead zone; SDL's Y grows downwards, the Switch's upwards. @@ -425,9 +433,10 @@ static int pad_read(uint64_t *b, int32_t sl[2], int32_t sr[2]) { uint8_t pd[PAD_COUNT] = {0}; if (!pad_physical(pd)) return 0; *b |= input_map_pad_buttons(pd, pad_layout_get()); - float dead = stick_dead_get(); - stick(SDL_GAMEPAD_AXIS_LEFTX, SDL_GAMEPAD_AXIS_LEFTY, dead, sl); - stick(SDL_GAMEPAD_AXIS_RIGHTX, SDL_GAMEPAD_AXIS_RIGHTY, dead, sr); + float dead[2]; + stick_dead_get(dead); + stick(SDL_GAMEPAD_AXIS_LEFTX, SDL_GAMEPAD_AXIS_LEFTY, dead[0], sl); + stick(SDL_GAMEPAD_AXIS_RIGHTX, SDL_GAMEPAD_AXIS_RIGHTY, dead[1], sr); return 1; } diff --git a/runtime/ui/menu.cpp b/runtime/ui/menu.cpp index 0e02e4bf..9f736d23 100644 --- a/runtime/ui/menu.cpp +++ b/runtime/ui/menu.cpp @@ -677,8 +677,10 @@ static void tab_controls(float scale) { opt_slider("TOTK_RUMBLE_STRENGTH", UI_MENU_RUMBLE_STRENGTH, 0, 300, UI_MENU_RUMBLE_STRENGTH_DESC, 10, "%.0f %%"); opt_slider("TOTK_RUMBLE_CURVE", UI_MENU_RUMBLE_CURVE, 0.2f, 1.5f, UI_MENU_RUMBLE_CURVE_DESC, 0.05f, "%.2f"); opt_toggle("TOTK_RUMBLE_TRIGGERS", UI_MENU_RUMBLE_TRIGGERS, UI_MENU_RUMBLE_TRIGGERS_DESC); - opt_slider("TOTK_STICK_DEADZONE", UI_MENU_STICK_DEADZONE, 0, 50, UI_MENU_STICK_DEADZONE_DESC, 1, "%.0f %%"); opt_slider("TOTK_MOUSE_SENS", UI_MENU_MOUSE_SENS, 0, 10, UI_MENU_MOUSE_SENS_DESC, 0.1f, "%.1f"); + opt_group(UI_MENU_GROUP_STICKS); + opt_slider("TOTK_LSTICK_DEADZONE", UI_MENU_LSTICK_DEADZONE, 0, 50, UI_MENU_LSTICK_DEADZONE_DESC, 1, "%.0f %%"); + opt_slider("TOTK_RSTICK_DEADZONE", UI_MENU_RSTICK_DEADZONE, 0, 50, UI_MENU_RSTICK_DEADZONE_DESC, 1, "%.0f %%"); binds_rows(scale); opt_end(); binds_after_page(); diff --git a/runtime/ui_text.c b/runtime/ui_text.c index f697e68c..51b5bd6b 100644 --- a/runtime/ui_text.c +++ b/runtime/ui_text.c @@ -132,8 +132,11 @@ static const char *const text_en[UI_TEXT_COUNT] = { [UI_MENU_PAD_LAYOUT_IN_USE] = "in use: %s", [UI_MENU_PAD_LAYOUT_PROMPTS_MISSING] = "prompt files missing: run the setup again", [UI_MENU_PAD_LAYOUT_PROMPTS_RESTART] = "prompts change after restart", - [UI_MENU_STICK_DEADZONE] = "Stick dead zone", - [UI_MENU_STICK_DEADZONE_DESC] = "How far a gamepad stick moves before the game sees it. Raise it if the character or the camera moves on its own.", + [UI_MENU_GROUP_STICKS] = "Analog sticks", + [UI_MENU_LSTICK_DEADZONE] = "Left stick dead zone", + [UI_MENU_LSTICK_DEADZONE_DESC] = "How far the left stick moves before the game sees it. Raise it if the character walks on their own.", + [UI_MENU_RSTICK_DEADZONE] = "Right stick dead zone", + [UI_MENU_RSTICK_DEADZONE_DESC] = "How far the right stick moves before the game sees it. Raise it if the camera turns on its own.", [UI_MENU_MOUSE_SENS] = "Mouse camera sensitivity", [UI_MENU_MOUSE_SENS_DESC] = "How fast the mouse turns the camera.", @@ -393,8 +396,11 @@ static const char *const text_ru[UI_TEXT_COUNT] = { [UI_MENU_PAD_LAYOUT_IN_USE] = "сейчас: %s", [UI_MENU_PAD_LAYOUT_PROMPTS_MISSING] = "нет файлов подсказок: запустите установку снова", [UI_MENU_PAD_LAYOUT_PROMPTS_RESTART] = "подсказки сменятся после перезапуска", - [UI_MENU_STICK_DEADZONE] = "Мёртвая зона стиков", - [UI_MENU_STICK_DEADZONE_DESC] = "Насколько нужно отклонить стик геймпада, чтобы игра это заметила. Увеличьте, если персонаж или камера двигаются сами.", + [UI_MENU_GROUP_STICKS] = "Стики", + [UI_MENU_LSTICK_DEADZONE] = "Мёртвая зона левого стика", + [UI_MENU_LSTICK_DEADZONE_DESC] = "Насколько нужно отклонить левый стик, чтобы игра это заметила. Увеличьте, если персонаж идёт сам.", + [UI_MENU_RSTICK_DEADZONE] = "Мёртвая зона правого стика", + [UI_MENU_RSTICK_DEADZONE_DESC] = "Насколько нужно отклонить правый стик, чтобы игра это заметила. Увеличьте, если камера поворачивается сама.", [UI_MENU_MOUSE_SENS] = "Чувствительность мыши", [UI_MENU_MOUSE_SENS_DESC] = "Как быстро мышь поворачивает камеру.", diff --git a/runtime/ui_text.h b/runtime/ui_text.h index a2088960..1a87fe74 100644 --- a/runtime/ui_text.h +++ b/runtime/ui_text.h @@ -66,7 +66,8 @@ typedef enum { UI_MENU_PAD_LAYOUT, UI_MENU_PAD_LAYOUT_DESC, UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO, UI_MENU_PAD_LAYOUT_XBOX, UI_MENU_PAD_LAYOUT_XBOX_PROMPTS, UI_MENU_PAD_LAYOUT_IN_USE, UI_MENU_PAD_LAYOUT_PROMPTS_MISSING, UI_MENU_PAD_LAYOUT_PROMPTS_RESTART, - UI_MENU_STICK_DEADZONE, UI_MENU_STICK_DEADZONE_DESC, + UI_MENU_GROUP_STICKS, UI_MENU_LSTICK_DEADZONE, UI_MENU_LSTICK_DEADZONE_DESC, + UI_MENU_RSTICK_DEADZONE, UI_MENU_RSTICK_DEADZONE_DESC, UI_MENU_MOUSE_SENS, UI_MENU_MOUSE_SENS_DESC, // Controls tab: the remapping rows (ui/binds_page.cpp) diff --git a/totk.ini.example b/totk.ini.example index 6d3d5f1e..bb88df2a 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -187,9 +187,11 @@ # labels (A with B, X with Y; it loads romfs-patches-xbox, docs/controls.md, "Xbox button prompts"). The bind_ lines # below apply on top. Changes while the game runs take effect at once; the xbox_prompts prompts at the next start. # pad_layout = auto -# Gamepad stick dead zone in percent of full deflection (0 to 50). Raise it for a stick that drifts: inside the circle -# the stick reads centred, and the rest of its travel is scaled to start there. -# stick_deadzone = 8 +# Gamepad stick dead zones in percent of full deflection (0 to 50), one per stick. Raise one for a stick that drifts: +# inside the circle the stick reads centred, and the rest of its travel is scaled to start there. A stick_deadzone +# line from an older totk.ini is carried over to both at the next start. +# lstick_deadzone = 8 +# rstick_deadzone = 8 # Mouse camera sensitivity (0 to 10; 0 = no mouse camera and no cursor capture). # mouse_sens = 1 # Write the input of this run to a file that replays as a script (docs/controls.md, "Scripted input"). Default: off. From 86a9ea6d53b042f5c3b2125aebde0fd1980185ba Mon Sep 17 00:00:00 2001 From: FaserDocs <89686851+FaserDocs@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:41:37 +0100 Subject: [PATCH 02/39] Read installer sources once as UTF-8 in the catalog test --- tests/installer_test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/installer_test.py b/tests/installer_test.py index 66cc31c6..d18a397f 100644 --- a/tests/installer_test.py +++ b/tests/installer_test.py @@ -520,9 +520,10 @@ class EventsTest(unittest.TestCase): catalog = json.loads((ROOT / 'installer' / 'text' / 'en.json').read_text(encoding='utf-8')) used = set() for path in (ROOT / 'installer').glob('*.py'): - used |= set(re.findall(r"InstallError\('([a-z_.]+)'", path.read_text())) - used |= set(re.findall(r"warning\('([a-z_.]+)'", path.read_text())) - used |= {f'phase.{s}.{p}' for s, p in re.findall(r"progress\('(\w+)', '(\w+)'[,)]", path.read_text())} + text = path.read_text(encoding='utf-8') + used |= set(re.findall(r"InstallError\('([a-z_.]+)'", text)) + used |= set(re.findall(r"warning\('([a-z_.]+)'", text)) + used |= {f'phase.{s}.{p}' for s, p in re.findall(r"progress\('(\w+)', '(\w+)'[,)]", text)} self.assertEqual(sorted(i for i in used if i not in catalog), []) From 88e044dcd3710092bc6d48cf14a7eee9a196a249 Mon Sep 17 00:00:00 2001 From: Wellington Dias Date: Sun, 1 Nov 2026 13:53:53 -0300 Subject: [PATCH 03/39] Mark a per-stick dead zone row as default at the shared value, and unset it when stepped back onto that value. --- runtime/ui/option_rows.cpp | 8 ++++++-- runtime/ui/option_rows.h | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/runtime/ui/option_rows.cpp b/runtime/ui/option_rows.cpp index 78725fd3..6074ca12 100644 --- a/runtime/ui/option_rows.cpp +++ b/runtime/ui/option_rows.cpp @@ -353,7 +353,10 @@ bool opt_slider(const char *key, UiTextId label, float lo, float hi, UiTextId de const char *set = cfg_get(key); const char *src = fallback && !(set && *set) ? fallback : key; // unset: show the value in effect, the fallback's const CfgMeta *m = cfg_meta(src); - double v = cfg_double(src), def = m ? strtod(m->def, NULL) : lo; + // A key without a table default of its own (the per-stick dead zones) defaults to its fallback: the "default" mark + // follows the fallback's default, not 0, even once the key is set. + if (fallback && m && !m->def[0]) m = cfg_meta(fallback); + double v = cfg_double(src), def = m && m->def[0] ? strtod(m->def, NULL) : lo; char shown[48]; snprintf(shown, sizeof shown, fmt, v); RowView rv = {shown, fabs(v - def) < step * 0.01, true, note}; @@ -367,7 +370,8 @@ bool opt_slider(const char *key, UiTextId label, float lo, float hi, UiTextId de char out[32]; if (step >= 1) snprintf(out, sizeof out, "%d", (int)lround(n)); else snprintf(out, sizeof out, "%g", round(n / step) * step); - apply(key, out); + // Landing on the fallback's value unsets the key (its line leaves totk.ini), so the row follows the fallback again. + apply(key, fallback && fabs(n - cfg_double(fallback)) < step * 0.01 ? "" : out); return true; } diff --git a/runtime/ui/option_rows.h b/runtime/ui/option_rows.h index 83cf4f87..b15933b3 100644 --- a/runtime/ui/option_rows.h +++ b/runtime/ui/option_rows.h @@ -61,7 +61,8 @@ void opt_group_text(const char *title); void opt_text_str(const char *text, bool warn = false); // lo..hi in steps of step (left and right held for a second go ten times as fast); fmt formats the value ("%.0f %%"). // note: a short line under the value in place of the "default" mark (NULL: none). fallback: the setting that applies -// while key is unset; the row shows its value then, and a change sets key. +// while key is unset; the row shows its value then, and a change sets key. A key with no table default of its own takes +// the "default" mark from the fallback's default, and a step onto the fallback's value unsets key again. bool opt_slider(const char *key, UiTextId label, float lo, float hi, UiTextId desc, float step = 1, const char *fmt = "%g", const char *note = nullptr, const char *fallback = nullptr); From 1722b5da2caa528adc41b61ea87663b7db8be140 Mon Sep 17 00:00:00 2001 From: Wellington Dias Date: Sun, 1 Nov 2026 16:21:54 -0300 Subject: [PATCH 04/39] Offer 256 as a sun shadow map size for integrated GPUs short of memory bandwidth --- runtime/cfg.c | 2 +- runtime/ui/menu.cpp | 2 +- runtime/ui_text.c | 11 +++++++---- totk.ini.example | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/runtime/cfg.c b/runtime/cfg.c index 015cda1b..64d0fda6 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -122,7 +122,7 @@ static const CfgMeta metas[] = { {"HDR", "0", "0|1", RESTART}, {"HDR_PEAK_NITS", "1000", "400..10000", LIVE}, {"HDR_GAME_NITS", "203", "80..500", LIVE}, {"HDR_UI_NITS", "203", "80..500", LIVE}, {"DEVICE_PROFILE", "auto", "auto|desktop|deck", RESTART}, - {"SHADOW_RES", "game", "game|512|768|1024|1536|2048", RESTART}, + {"SHADOW_RES", "game", "game|256|512|768|1024|1536|2048", RESTART}, {"SHADOW_SCALE", "1", "1..4", RESTART}, {"SHADOW_RANGE", "100", "50..400", RESTART}, {"CONTACT_SHADOWS", "0", "0..100", RESTART}, {"PCSS", "0", "0..200", RESTART}, diff --git a/runtime/ui/menu.cpp b/runtime/ui/menu.cpp index 151354e0..c6a2ac7a 100644 --- a/runtime/ui/menu.cpp +++ b/runtime/ui/menu.cpp @@ -518,7 +518,7 @@ static void row_render_scale(void) { } static void row_shadow_res(void) { - static const char *const vals[] = {"game", "512", "768", "1024", "1536", "2048"}; + static const char *const vals[] = {"game", "256", "512", "768", "1024", "1536", "2048"}; enum { N = sizeof vals / sizeof *vals }; const char *names[N]; names[0] = menu_text(UI_MENU_SHADOW_RES_GAME); diff --git a/runtime/ui_text.c b/runtime/ui_text.c index 5c14ed53..dc845006 100644 --- a/runtime/ui_text.c +++ b/runtime/ui_text.c @@ -74,7 +74,8 @@ static const char *const text_en[UI_TEXT_COUNT] = { [UI_MENU_SHADOW_RES] = "Shadow map size", [UI_MENU_SHADOW_RES_DESC] = "Size of the sun's shadow map. The game's own size changes with the view, down to about 400 " "near the ground, so leaf shadows look blocky and jump; a fixed size keeps them still and " - "sharper. Above 1024 the shadows cost more GPU time and video memory.", + "sharper. Above 1024 the shadows cost more GPU time and video memory; 256 is coarser than the " + "game's own and is for integrated GPUs short of memory bandwidth.", [UI_MENU_SHADOW_RES_GAME] = "Game (changes)", [UI_MENU_ANISO] = "Texture filtering", [UI_MENU_ANISO_DESC] = "Anisotropic filtering of surface textures: ground, roads and walls seen at a shallow angle stay sharp into the distance instead of blurring. Game: the level the game asks for, as on the Switch.", @@ -498,7 +499,8 @@ static const char *const text_ru[UI_TEXT_COUNT] = { [UI_MENU_SHADOW_RES] = "Размер карты теней", [UI_MENU_SHADOW_RES_DESC] = "Размер карты теней от солнца. Собственный размер игры меняется вместе с видом, у земли до " "примерно 400, поэтому тени листвы выглядят квадратными и дёргаются; постоянный размер делает " - "их ровными и чётче. Выше 1024 тени требуют больше времени GPU и видеопамяти.", + "их ровными и чётче. Выше 1024 тени требуют больше времени GPU и видеопамяти; 256 грубее " + "собственного размера игры и предназначен для встроенных GPU с нехваткой пропускной способности памяти.", [UI_MENU_SHADOW_RES_GAME] = "Как в игре (меняется)", [UI_MENU_ANISO] = "Фильтрация текстур", [UI_MENU_ANISO_DESC] = "Анизотропная фильтрация текстур поверхностей: земля, дороги и стены под острым углом остаются чёткими вдали, а не размываются. Игра: уровень, который запрашивает игра, как на Switch.", @@ -1196,7 +1198,8 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = { [UI_MENU_SHADOW_RES_DESC] = "Tamanho do mapa de sombras do sol. O tamanho do jogo muda com a vista, até cerca de " "400 perto do chão, então as sombras das folhas ficam quadriculadas e pulam; um " "tamanho fixo as deixa paradas e mais nítidas. Acima de 1024 as sombras custam mais " - "tempo de GPU e memória de vídeo.", + "tempo de GPU e memória de vídeo; 256 é mais grosseiro que o do jogo e serve para GPUs " + "integradas com pouca largura de banda de memória.", [UI_MENU_SHADOW_RES_GAME] = "Do jogo (varia)", [UI_MENU_ANISO] = "Filtragem de texturas", [UI_MENU_ANISO_DESC] = "Filtragem anisotrópica das texturas de superfície: chão, estradas e paredes vistos em " @@ -1795,7 +1798,7 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = { [UI_MENU_OV_SCALE_BILINEAR] = "双线性", [UI_MENU_SHADOW_RES] = "阴影贴图尺寸", [UI_MENU_SHADOW_RES_DESC] = "太阳阴影贴图的尺寸。游戏自身的尺寸随视角变化,靠近地面时降到约 400,因此树叶阴影显得块状并且跳动;固定尺寸能让阴影稳定、更清晰。高于 1024 时阴影会占用更多 " - "GPU 时间和显存。", + "GPU 时间和显存;256 比游戏自身的尺寸更粗糙,适合显存带宽不足的集成显卡。", [UI_MENU_SHADOW_RES_GAME] = "游戏默认(会变化)", [UI_MENU_ANISO] = "纹理过滤", [UI_MENU_ANISO_DESC] = "表面纹理的各向异性过滤:以小角度看到的地面、道路和墙壁在远处依然清晰,而不会模糊。游戏:使用游戏请求的级别,与 Switch 上相同。", diff --git a/totk.ini.example b/totk.ini.example index 227a923f..d326e511 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -160,8 +160,8 @@ # color_saturation = 100 # color_gamma = 1 # Sun shadow map size: game (the game's own, which changes with the view and drops to about 400 near the ground, so -# leaf shadows look blocky and jump) or a fixed 512, 768, 1024, 1536 or 2048. Above 960 the texture heap grows with it. -# Read at start. +# leaf shadows look blocky and jump) or a fixed 256, 512, 768, 1024, 1536 or 2048. Above 960 the texture heap grows with +# it. 256 is coarser than the game's own and is for integrated GPUs short of memory bandwidth. Read at start. # shadow_res = game # Shadow map resolution: the game's sun cascades are 960x960 (and its static shadow maps 1440 and 720) whatever the game From d3f403ecf67f3c69e5fab9b3ab73c4b71740826e Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Sun, 1 Nov 2026 22:54:20 +0300 Subject: [PATCH 05/39] Delete read-only folders and cloud placeholders of an old save working copy, retry for 2 s, and name the failing path in the fatal Co-Authored-By: Claude Sonnet 5.5 --- runtime/fs_host.c | 53 +++++++++++++++++++++++++++++++++---- runtime/platform/linux.c | 2 ++ runtime/platform/platform.h | 3 ++- runtime/platform/win32.c | 35 +++++++++++++++++++----- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/runtime/fs_host.c b/runtime/fs_host.c index 90eaa8dc..3af390a7 100644 --- a/runtime/fs_host.c +++ b/runtime/fs_host.c @@ -125,12 +125,30 @@ static void make_dirs(const char *p) { if (!make_dir(buf)) rt_fatal("fs: cannot create directory %s", buf); } -// Remove a directory tree; a link (reparse point, symbolic link) is removed as a link, never followed. 0 also when +// The first path remove_tree could not delete and the OS error code, for the message of a fatal. +static char remove_failed[1100]; +static int remove_error; +static void remove_note(const char *path) { + if (*remove_failed) return; + snprintf(remove_failed, sizeof remove_failed, "%s", path); + remove_error = plat_fs_os_error(); +} + +// Remove a directory tree; a link (symbolic link, junction) is removed as a link, never followed. 0 also when // nothing is there. static int remove_tree(const char *path) { int kind = plat_fs_kind(path); if (kind == PLAT_FS_MISSING) return 0; - if (kind == PLAT_FS_FILE) return plat_fs_unlink(path) ? -1 : 0; + if (kind == PLAT_FS_FILE) { + if (!plat_fs_unlink(path)) return 0; + remove_note(path); + return -1; + } + if (kind == PLAT_FS_LINK) { // a link to a file goes by unlink, a link to a directory by rmdir + if (!plat_fs_unlink(path) || !plat_fs_rmdir(path)) return 0; + remove_note(path); + return -1; + } int bad = 0; if (kind == PLAT_FS_DIR) { Listing l; @@ -142,7 +160,23 @@ static int remove_tree(const char *path) { } listing_free(l.e, l.n); } - return bad | (plat_fs_rmdir(path) ? -1 : 0); + if (plat_fs_rmdir(path)) { + remove_note(path); + bad |= -1; + } + return bad; +} + +// remove_tree, tried again every 50 ms for up to 2 s while another process (the previous run still exiting, a virus +// scanner, a sync client) holds a file in the tree. The failing path and its OS error stay in remove_failed. +#define MOVE_TRIES 40 // 2 s, also move_dir's +static int remove_tree_retry(const char *path) { + for (int i = 0;; i++) { + *remove_failed = 0; + if (!remove_tree(path)) return 0; + if (i == MOVE_TRIES) return -1; + plat_sleep_ms(50); + } } // ---- save transactions @@ -190,7 +224,6 @@ static int exists(const char *p) { return plat_fs_kind(p) != PLAT_FS_MISSING; } // Rename a directory, durably; retried every 50 ms, tries times in all, while another process (a virus scanner, the // indexer) holds a file in it. 0 on success. -#define MOVE_TRIES 40 // 2 s static int move_dir(const char *from, const char *to, int tries) { for (int i = 0; i < tries; i++) { int e = plat_fs_rename(from, to, 1); @@ -446,7 +479,8 @@ static void save_recover(void) { did = swap_in() ? "a finished commit still waits in save-tx; the game loads it from there" : "completed a finished commit"; if (exists(work_root)) { - if (remove_tree(work_root)) rt_fatal("fs: cannot delete the old save working copy"); + if (remove_tree_retry(work_root)) + rt_fatal("fs: cannot delete the old save working copy: %s (OS error %d)", remove_failed, remove_error); if (!strcmp(did, "nothing to recover")) did = "deleted the last run's working copy (changes after its last Commit)"; } @@ -1575,6 +1609,15 @@ static void probe_save_tx(const uint64_t *uid) { CALL(S_UNMOUNT, U("ro")); CHECK(RES(S_DELETE_FILE, U(a)) == 0 && RES(S_DELETE_FILE, U(b)) == 0 && tx_commit() == 0 && tx_read_committed("tx_a.sav") == -1, "Commit of deletions"); + // a run that left read-only folders and files in the working copy (attributes another emulator's save carried): + // the next start deletes them and names the failing path if it cannot + char *ro_dir = join(work_root, "ro_dir"), *ro_file = join(ro_dir, "ro.sav"); + CHECK(!plat_fs_mkdir(ro_dir) && plat_file_write(ro_file, "x", 1) && plat_file_set_readonly(ro_file, 1) && + plat_file_set_readonly(ro_dir, 1), "read-only leftovers in the working copy"); + probe_restart(uid); + CHECK(!exists(ro_dir), "the next start deleted a working copy with read-only folders"); + free(ro_dir); + free(ro_file); printf("save tx: uncommitted writes visible to the game and dropped by a restart and by Unmount, commits kept; a " "crash at each of %d commit steps recovers to the old save %d times, the new one %d times, mixed %d " "times; %d backups\n", steps, olds, news, mixed, backups); diff --git a/runtime/platform/linux.c b/runtime/platform/linux.c index 7d502925..629dc0fe 100644 --- a/runtime/platform/linux.c +++ b/runtime/platform/linux.c @@ -1038,6 +1038,8 @@ const void *plat_file_map(PlatFile *f) { return v == MAP_FAILED ? NULL : v; } +int plat_fs_os_error(void) { return errno; } + static int fs_err(int e) { switch (e) { case ENOENT: case ENOTDIR: return PLAT_FS_NOT_FOUND; diff --git a/runtime/platform/platform.h b/runtime/platform/platform.h index b8e0c74b..f5104a1f 100644 --- a/runtime/platform/platform.h +++ b/runtime/platform/platform.h @@ -316,8 +316,9 @@ enum { PLAT_FS_OK, PLAT_FS_NOT_FOUND, PLAT_FS_EXISTS, PLAT_FS_NOT_EMPTY, PLAT_FS enum { PLAT_FS_MISSING = -1, PLAT_FS_FILE, PLAT_FS_DIR, PLAT_FS_LINK }; // plat_fs_kind; LINK: a symbolic link or // reparse point, never followed int plat_fs_kind(const char *path); +int plat_fs_os_error(void); // the OS error code (GetLastError, errno) of this thread's last failed plat_fs_* call int plat_fs_mkdir(const char *path); // PLAT_FS_EXISTS when something is there -int plat_fs_rmdir(const char *path); // an empty directory, or a link to one +int plat_fs_rmdir(const char *path); // an empty directory (a read-only one too), or a link to one int plat_fs_unlink(const char *path); // a file or a link; a read-only one too // Renames a file or directory; never replaces (PLAT_FS_EXISTS). durable: the change is on the disk at return. int plat_fs_rename(const char *from, const char *to, int durable); diff --git a/runtime/platform/win32.c b/runtime/platform/win32.c index 6d83b6a3..3934f87f 100644 --- a/runtime/platform/win32.c +++ b/runtime/platform/win32.c @@ -866,7 +866,11 @@ const void *plat_file_map(PlatFile *f) { return v; } +static __thread DWORD fs_last_error; +int plat_fs_os_error(void) { return (int)fs_last_error; } + static int fs_err(DWORD e) { + fs_last_error = e; switch (e) { case ERROR_FILE_NOT_FOUND: case ERROR_PATH_NOT_FOUND: case ERROR_DIRECTORY: return PLAT_FS_NOT_FOUND; case ERROR_FILE_EXISTS: case ERROR_ALREADY_EXISTS: return PLAT_FS_EXISTS; @@ -876,12 +880,23 @@ static int fs_err(DWORD e) { } } +// A symbolic link or junction is a link: removed as a link, never followed. Any other reparse point (OneDrive's cloud +// placeholders, deduplicated files) is an ordinary file or directory whose content the file system fetches on access. +static int fs_kind_of(const WIN32_FIND_DATAW *fd) { + DWORD a = fd->dwFileAttributes; + if (a & FILE_ATTRIBUTE_REPARSE_POINT && (fd->dwReserved0 == IO_REPARSE_TAG_SYMLINK || fd->dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT)) + return PLAT_FS_LINK; + return a & FILE_ATTRIBUTE_DIRECTORY ? PLAT_FS_DIR : PLAT_FS_FILE; +} + int plat_fs_kind(const char *path) { wchar_t w[WPATH]; if (!widen(path, w, WPATH)) return PLAT_FS_MISSING; - DWORD a = GetFileAttributesW(w); - if (a == INVALID_FILE_ATTRIBUTES) return PLAT_FS_MISSING; - return a & FILE_ATTRIBUTE_REPARSE_POINT ? PLAT_FS_LINK : a & FILE_ATTRIBUTE_DIRECTORY ? PLAT_FS_DIR : PLAT_FS_FILE; + WIN32_FIND_DATAW fd; + HANDLE h = FindFirstFileW(w, &fd); // the reparse tag comes with the find data only + if (h == INVALID_HANDLE_VALUE) return PLAT_FS_MISSING; + FindClose(h); + return fs_kind_of(&fd); } int plat_fs_mkdir(const char *path) { @@ -893,7 +908,15 @@ int plat_fs_mkdir(const char *path) { int plat_fs_rmdir(const char *path) { wchar_t w[WPATH]; if (!widen(path, w, WPATH)) return PLAT_FS_ERROR; - return RemoveDirectoryW(w) ? PLAT_FS_OK : fs_err(GetLastError()); + if (RemoveDirectoryW(w)) return PLAT_FS_OK; + DWORD e = GetLastError(), a = GetFileAttributesW(w); + if (e == ERROR_ACCESS_DENIED && a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_READONLY)) { + // a read-only directory (copied over from another emulator's save folder) refuses RemoveDirectoryW + SetFileAttributesW(w, a & ~(DWORD)FILE_ATTRIBUTE_READONLY); + if (RemoveDirectoryW(w)) return PLAT_FS_OK; + e = GetLastError(); + } + return fs_err(e); } int plat_fs_unlink(const char *path) { @@ -1038,9 +1061,7 @@ int plat_fs_list(const char *dir, int (*cb)(const char *name, int kind, uint64_t if (!wcscmp(fd.cFileName, L".") || !wcscmp(fd.cFileName, L"..")) continue; char name[1024]; if (!WideCharToMultiByte(CP_UTF8, 0, fd.cFileName, -1, name, sizeof name, NULL, NULL)) continue; - DWORD a = fd.dwFileAttributes; - int kind = a & FILE_ATTRIBUTE_REPARSE_POINT ? PLAT_FS_LINK : a & FILE_ATTRIBUTE_DIRECTORY ? PLAT_FS_DIR - : PLAT_FS_FILE; + int kind = fs_kind_of(&fd); if (!cb(name, kind, (uint64_t)fd.nFileSizeHigh << 32 | fd.nFileSizeLow, ctx)) break; } while (FindNextFileW(h, &fd)); FindClose(h); From 6d31b157a009030272a9981354d89036de1d7e79 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Sun, 1 Nov 2026 22:55:27 +0300 Subject: [PATCH 06/39] Ask FindFirstFileW for the reparse tag only when the path is a reparse point plat_fs_kind served every path through FindFirstFileW, which fails on a path with a trailing separator and reads wildcards. GetFileAttributesW decides again, as before; only reparse points need the find data's tag. Co-Authored-By: Claude Opus 5.5 --- runtime/platform/win32.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/runtime/platform/win32.c b/runtime/platform/win32.c index 3934f87f..1d70c18d 100644 --- a/runtime/platform/win32.c +++ b/runtime/platform/win32.c @@ -892,9 +892,14 @@ static int fs_kind_of(const WIN32_FIND_DATAW *fd) { int plat_fs_kind(const char *path) { wchar_t w[WPATH]; if (!widen(path, w, WPATH)) return PLAT_FS_MISSING; + DWORD a = GetFileAttributesW(w); + if (a == INVALID_FILE_ATTRIBUTES) return PLAT_FS_MISSING; + if (!(a & FILE_ATTRIBUTE_REPARSE_POINT)) return a & FILE_ATTRIBUTE_DIRECTORY ? PLAT_FS_DIR : PLAT_FS_FILE; + // the reparse tag comes with the find data only; FindFirstFileW is asked only here, since it fails on a path + // with a trailing separator and reads wildcards WIN32_FIND_DATAW fd; - HANDLE h = FindFirstFileW(w, &fd); // the reparse tag comes with the find data only - if (h == INVALID_HANDLE_VALUE) return PLAT_FS_MISSING; + HANDLE h = FindFirstFileW(w, &fd); + if (h == INVALID_HANDLE_VALUE) return PLAT_FS_LINK; FindClose(h); return fs_kind_of(&fd); } From 526e9f32bda15c7ba2313070c50f924cfc298a6e Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Sun, 1 Nov 2026 23:27:33 +0300 Subject: [PATCH 07/39] Retry the lift's emit pool with half the workers after a MemoryError, and read the worker count from TOTK_LIFT_JOBS Co-Authored-By: Claude Sonnet 5.5 --- docs/building.md | 2 ++ recomp/gen.py | 50 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/building.md b/docs/building.md index 9dddb2b0..08975f8c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -97,6 +97,8 @@ of them. `--cpu v2` targets x86-64-v2 (SSE4.2, no AVX2/FMA) for older CPUs; the default `v3` needs AVX2. The v2 build links `runtime/fma.c`, a correctly rounded software fma the FP lowering needs, and runs slower. + `gen.py` sizes its emit pool from the CPU count and the free memory; `--jobs N` sets it, and so does the environment variable `TOTK_LIFT_JOBS=N`, which the installer's lift step honours too (it has no `--jobs`). When a worker runs out of memory, the pool restarts with half the workers (`emit: out of memory with N workers, retrying with M`), down to one; only an out-of-memory error with one worker fails the step. + Lifting takes about 15 s, a clean compile about 200 s on 24 threads; later builds recompile only what changed. A compiler cache in front of clang shortens it further: with `SCCACHE` set to an sccache executable, `build_game.py` writes it into the ninja file ahead of `cc`, and every lift rewrites the generated chunks with the same contents, so diff --git a/recomp/gen.py b/recomp/gen.py index c43a6d8c..bd582b68 100644 --- a/recomp/gen.py +++ b/recomp/gen.py @@ -558,7 +558,8 @@ def _retry(fn, *args): _W = {} # worker state: 'mods' {name: (path, base)}, 'starts' {name: sorted array}, 'emitters' {name: Emitter} -def _worker_init(mods, starts, table, opts): +def _worker_init(mods, starts, table, opts, jobs): + _W['jobs'] = jobs _W['mods'] = mods _W['starts'] = starts _W['patches'] = table @@ -581,6 +582,10 @@ def _emit_chunk(task): """Worker: write one chunk. Returns its statistics.""" name, number, path, fns = task t0 = time.perf_counter() + # Test hook: a pool wider than this raises the MemoryError a busy machine does, so the retry can be exercised. + limit = os.environ.get('TOTK_LIFT_TEST_OOM_ABOVE') + if limit and _W['jobs'] > int(limit) and number % 2 == 1: + raise MemoryError e = _emitter(name) e.stats.clear() e.unsupported.clear() @@ -632,6 +637,14 @@ def pick_jobs(requested): """Worker count and the reason for it: --jobs if given, else the CPU count capped by the free memory.""" if requested: return requested, '--jobs' + env = os.environ.get('TOTK_LIFT_JOBS', '').strip() + if env: + try: + if int(env) >= 1: + return int(env), 'TOTK_LIFT_JOBS' + except ValueError: + pass + print(f'ignoring TOTK_LIFT_JOBS={env!r}: not a positive integer', file=sys.stderr, flush=True) cpus = os.cpu_count() or 4 free = hostmem.available_ram() if free is None: @@ -705,16 +718,31 @@ def main(): t_emit = time.perf_counter() ctx = mp.get_context('spawn') jobs, why = pick_jobs(args.jobs) - print(f'emitting with {jobs} workers ({why})', file=sys.stderr, flush=True) - with ctx.Pool(jobs, initializer=_worker_init, initargs=(paths, starts_abs, table, opts)) as pool: - for i, r in enumerate(pool.imap_unordered(_emit_chunk, [t for _, t in tasks], chunksize=1)): - results[r['module']].append(r) - if r['error']: - errors.append(r['error']) - done_at[r['module']] = time.perf_counter() - t_emit - if (i + 1) % 200 == 0 or i + 1 == len(tasks): - print(f'emitted {i + 1}/{len(tasks)} chunks, {time.perf_counter() - t_emit:.0f}s', file=sys.stderr, - flush=True) + pending = [t for _, t in tasks] + emitted = 0 + while True: + print(f'emitting with {jobs} workers ({why})', file=sys.stderr, flush=True) + try: + # Chunk writes are atomic and idempotent, so a chunk lost with a failed pool is simply emitted again. + with ctx.Pool(jobs, initializer=_worker_init, initargs=(paths, starts_abs, table, opts, jobs)) as pool: + for r in pool.imap_unordered(_emit_chunk, pending, chunksize=1): + results[r['module']].append(r) + if r['error']: + errors.append(r['error']) + done_at[r['module']] = time.perf_counter() - t_emit + emitted += 1 + if emitted % 200 == 0 or emitted == len(tasks): + print(f'emitted {emitted}/{len(tasks)} chunks, {time.perf_counter() - t_emit:.0f}s', + file=sys.stderr, flush=True) + break + except MemoryError: + if jobs == 1: + raise + smaller = jobs // 2 + print(f'emit: out of memory with {jobs} workers, retrying with {smaller}', file=sys.stderr, flush=True) + jobs, why = smaller, 'after out of memory' + done = {(r['module'], r['number']) for rs in results.values() for r in rs} + pending = [t for t in pending if (t[0], t[1]) not in done] for name, d in info.items(): rs = sorted(results[name], key=lambda r: r['number']) From 2470097f6744d60b269caccf373969f9baebdce2 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Sun, 1 Nov 2026 23:29:26 +0300 Subject: [PATCH 08/39] Fail the Linux setup in the check step when the plan prepares shaders and no display is set, and say in the font warning that the game will not start without the Switch system fonts --- docs/installer.md | 14 +++++++++++++- installer/steps.py | 4 ++++ installer/text/en.json | 3 ++- installer/text/pt-BR.json | 3 ++- installer/text/ru.json | 3 ++- installer/text/zh-Hans.json | 3 ++- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/installer.md b/docs/installer.md index 7e5339a6..8a8e6b78 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -149,11 +149,23 @@ totk.ini.example, VERSION the default ini; the release string (the repository's - The release contains nothing game-derived; `make_release.py` runs `tools/check_content.py` over the folder before it finishes. +## Switch system fonts + +Simplified Chinese, Traditional Chinese and Korean text needs the Switch system fonts (`0-standard.bfttf` and the other +nn::pl shared fonts). The game does not ship them. Without them it exits before its first frame in those languages +(`rt_fatal` in `runtime/ov_system.c`). Supply them from your own console, in either way: + +- Give the setup the firmware folder (`--firmware DIR`, or the window's firmware field); the Fonts step extracts them to + `shared-fonts/` in the install folder. +- Run `python tools/extract_fonts.py --firmware ` with `.venv-py314` and the keys from `config.local.toml`, or + point `TOTK_SHARED_FONTS` at a folder that holds the extracted fonts. + ## Linux release `tools/make_release_linux.py` builds the same setup for Linux and packs it as `TotK-Setup-x86_64.AppImage` (the static type-2 runtime: no libfuse2 needed on the host). The player makes it executable and starts it; on a Steam Deck, from -Desktop Mode. Building the release also needs `make` and `pkg-config`. The bundled Tcl/Tk 9.0.4 is rebuilt with +Desktop Mode. The command-line setup needs the desktop session's display (`DISPLAY` or `WAYLAND_DISPLAY`) because preparing the +shaders opens a window; the check step fails at once without it. Building the release also needs `make` and `pkg-config`. The bundled Tcl/Tk 9.0.4 is rebuilt with Xft against the sniper sysroot so Russian text uses system TrueType fonts. The setup builds the game into the folder they pick, as on Windows, and the game is `totk` there with `libSDL3.so.0` beside it. The folder inside the AppImage: diff --git a/installer/steps.py b/installer/steps.py index 54130b98..174e6542 100644 --- a/installer/steps.py +++ b/installer/steps.py @@ -349,6 +349,10 @@ class Check(Step): raise InstallError('check.keys_unreadable') from None if 'header_key' not in key_values: raise InstallError('check.keys_no_header_key') + if sys.platform == 'linux' and ctx.will_run('prepare') and not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')): + # totk --prepare-shaders opens a window and needs a present-capable queue family; without a display it + # fails after lift and compile (~15 min), so stop here + raise InstallError('check.no_display') ctx.lang = lang ctx.temp.mkdir(parents=True, exist_ok=True) diff --git a/installer/text/en.json b/installer/text/en.json index 16456ae1..b054e806 100644 --- a/installer/text/en.json +++ b/installer/text/en.json @@ -51,6 +51,7 @@ "check.vulkan_missing": "No Vulkan driver is installed. Install the latest driver for your graphics card.", "check.vulkan_no_device": "Vulkan finds no graphics card. Install the latest driver for your graphics card.", "check.vulkan_too_old": "The game needs a graphics card with Vulkan 1.3; found: {devices}. Update the graphics driver.", + "check.no_display": "Preparing the shaders needs a display. Run the setup from the desktop session, or export DISPLAY=:0 (on a Steam Deck in Game Mode also XDG_RUNTIME_DIR=/run/user/1000).", "check.inputs_needed": "The game files are needed: choose the base game, the 1.1.0 or 1.2.1 or 1.4.3 update and prod.keys.", "check.input_missing": "File not found ({input}): {path}", "check.keys_unreadable": "The keys file cannot be read. Choose a prod.keys file.", @@ -93,7 +94,7 @@ "prepare.pack_failed": "Packing the shader cache failed (exit code {code}). Details: {log}", "cleanup.ini_failed": "totk.ini cannot be written: {detail}", - "warn.lang_needs_fonts": "Text in {lang} needs the Switch system fonts. Choose the firmware folder (Contents/registered) to install them; without it some text may not show.", + "warn.lang_needs_fonts": "Text in {lang} needs the Switch system fonts, and none were given. The game will not start in this language until they are added. Choose the firmware folder (Contents/registered), or see \"Switch system fonts\" in docs/installer.md.", "warn.npdm_differs": "main.npdm differs from the reference {version} dump; only the code modules are used.", "warn.cheats_skipped": "The cheats of the mods folder that change the game's code could not be built; the game installs without them. Details: {log}", "warn.exefs_skipped": "The mods' ExeFS patches (.ips, .pchtxt) could not be built; the game installs without them. Details: {log}", diff --git a/installer/text/pt-BR.json b/installer/text/pt-BR.json index 1d5b8df6..0cdcbc0a 100644 --- a/installer/text/pt-BR.json +++ b/installer/text/pt-BR.json @@ -51,6 +51,7 @@ "check.vulkan_missing": "Nenhum driver Vulkan está instalado. Instale o driver mais recente da sua placa de vídeo.", "check.vulkan_no_device": "O Vulkan não encontra nenhuma placa de vídeo. Instale o driver mais recente da sua placa de vídeo.", "check.vulkan_too_old": "O jogo precisa de uma placa de vídeo com Vulkan 1.3; encontrado: {devices}. Atualize o driver de vídeo.", + "check.no_display": "Preparar os shaders exige uma tela. Execute a instalação na sessão da área de trabalho ou use export DISPLAY=:0 (no Steam Deck em Game Mode, também XDG_RUNTIME_DIR=/run/user/1000).", "check.inputs_needed": "Os arquivos do jogo são necessários: escolha o jogo base, a atualização 1.1.0, 1.2.1 ou 1.4.3 e o prod.keys.", "check.input_missing": "Arquivo não encontrado ({input}): {path}", "check.keys_unreadable": "Não foi possível ler o arquivo de chaves. Escolha um arquivo prod.keys.", @@ -93,7 +94,7 @@ "prepare.pack_failed": "Falha ao juntar o cache de shaders (código de saída {code}). Detalhes: {log}", "cleanup.ini_failed": "Não foi possível gravar o totk.ini: {detail}", - "warn.lang_needs_fonts": "Textos em {lang} precisam das fontes de sistema do Switch, que o instalador não extrai; parte do texto pode não aparecer.", + "warn.lang_needs_fonts": "Textos em {lang} precisam das fontes de sistema do Switch, e nenhuma foi informada. O jogo não inicia neste idioma até que sejam adicionadas. Escolha a pasta do firmware (Contents/registered) ou veja \"Switch system fonts\" em docs/installer.md.", "warn.cheats_skipped": "Não foi possível compilar os cheats da pasta de mods que alteram o código do jogo; o jogo é instalado sem eles. Detalhes: {log}", "warn.exefs_skipped": "Não foi possível compilar os patches ExeFS dos mods (.ips, .pchtxt); o jogo é instalado sem eles. Detalhes: {log}", "warn.npdm_differs": "O main.npdm difere do dump de referência da versão {version}; só os módulos de código são usados.", diff --git a/installer/text/ru.json b/installer/text/ru.json index d16e0c0e..e583dd71 100644 --- a/installer/text/ru.json +++ b/installer/text/ru.json @@ -51,6 +51,7 @@ "check.vulkan_missing": "Драйвер Vulkan не установлен. Установите свежий драйвер видеокарты.", "check.vulkan_no_device": "Vulkan не находит видеокарту. Установите свежий драйвер видеокарты.", "check.vulkan_too_old": "Игре нужна видеокарта с Vulkan 1.3; найдено: {devices}. Обновите драйвер видеокарты.", + "check.no_display": "Для подготовки шейдеров нужен дисплей. Запустите установку из сеанса рабочего стола или выполните export DISPLAY=:0 (на Steam Deck в игровом режиме ещё XDG_RUNTIME_DIR=/run/user/1000).", "check.inputs_needed": "Нужны игровые файлы: выберите базовую игру, обновление 1.1.0 или 1.2.1 или 1.4.3 и prod.keys.", "check.input_missing": "Файл не найден ({input}): {path}", "check.keys_unreadable": "Не удаётся прочитать файл ключей. Выберите файл prod.keys.", @@ -93,7 +94,7 @@ "prepare.pack_failed": "Не удалось упаковать кэш шейдеров (код выхода {code}). Подробности: {log}", "cleanup.ini_failed": "Не удаётся записать totk.ini: {detail}", - "warn.lang_needs_fonts": "Для языка «{lang}» нужны системные шрифты Switch. Выберите папку прошивки (Contents/registered), чтобы установить их; без неё часть текста может не отображаться.", + "warn.lang_needs_fonts": "Для языка «{lang}» нужны системные шрифты Switch, а они не указаны. Игра не запустится на этом языке, пока их не добавят. Выберите папку прошивки (Contents/registered) или см. раздел «Switch system fonts» в docs/installer.md.", "warn.npdm_differs": "main.npdm отличается от эталонного дампа {version}; используются только модули с кодом.", "warn.cheats_skipped": "Не удалось собрать читы из папки модов, меняющие код игры; игра установится без них. Подробности: {log}", "warn.exefs_skipped": "Не удалось собрать ExeFS-патчи модов (.ips, .pchtxt); игра установится без них. Подробности: {log}", diff --git a/installer/text/zh-Hans.json b/installer/text/zh-Hans.json index bf366337..d858c38e 100644 --- a/installer/text/zh-Hans.json +++ b/installer/text/zh-Hans.json @@ -51,6 +51,7 @@ "check.vulkan_missing": "未安装 Vulkan 驱动。请为你的显卡安装最新的驱动程序。", "check.vulkan_no_device": "Vulkan 找不到显卡。请为你的显卡安装最新的驱动程序。", "check.vulkan_too_old": "游戏需要支持 Vulkan 1.3 的显卡;检测到:{devices}。请更新显卡驱动程序。", + "check.no_display": "准备着色器需要显示器。请在桌面会话中运行安装程序,或执行 export DISPLAY=:0(Steam Deck 游戏模式下还需 XDG_RUNTIME_DIR=/run/user/1000)。", "check.inputs_needed": "需要游戏文件:请选择游戏本体、1.1.0、1.2.1 或 1.4.3 更新以及 prod.keys。", "check.input_missing": "找不到文件({input}):{path}", "check.keys_unreadable": "无法读取密钥文件。请选择一个 prod.keys 文件。", @@ -93,7 +94,7 @@ "prepare.pack_failed": "打包着色器缓存失败(退出代码 {code})。详情:{log}", "cleanup.ini_failed": "无法写入 totk.ini:{detail}", - "warn.lang_needs_fonts": "{lang}文本需要 Switch 系统字体,而安装程序不会提取这些字体;部分文字可能无法显示。", + "warn.lang_needs_fonts": "{lang}文本需要 Switch 系统字体,但未提供。在添加这些字体之前,游戏无法以该语言启动。请选择固件文件夹(Contents/registered),或参阅 docs/installer.md 中的“Switch system fonts”一节。", "warn.cheats_skipped": "无法构建 mods 文件夹中修改游戏代码的金手指;游戏将不含它们进行安装。详情:{log}", "warn.exefs_skipped": "无法构建模组的 ExeFS 补丁(.ips、.pchtxt);游戏将不含它们进行安装。详情:{log}", "warn.npdm_differs": "main.npdm 与参考的 {version} 导出不同;只使用其中的代码模块。", From b3f569a448f0dc6b1cc9fa8999454e38a95cf383 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Sun, 1 Nov 2026 23:29:54 +0300 Subject: [PATCH 09/39] Wrap the display check's condition over two lines Co-Authored-By: Claude Opus 5.5 --- installer/steps.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/installer/steps.py b/installer/steps.py index 174e6542..a99a0660 100644 --- a/installer/steps.py +++ b/installer/steps.py @@ -349,7 +349,8 @@ class Check(Step): raise InstallError('check.keys_unreadable') from None if 'header_key' not in key_values: raise InstallError('check.keys_no_header_key') - if sys.platform == 'linux' and ctx.will_run('prepare') and not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')): + if (sys.platform == 'linux' and ctx.will_run('prepare') + and not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY'))): # totk --prepare-shaders opens a window and needs a present-capable queue family; without a display it # fails after lift and compile (~15 min), so stop here raise InstallError('check.no_display') From ec9029def487b8ae202c4f258d1d05f108dd02e5 Mon Sep 17 00:00:00 2001 From: deanzx06 Date: Mon, 2 Nov 2026 05:17:58 +0700 Subject: [PATCH 10/39] runtime: vectorized AVX round-to-odd FMA emulation for non-FMA/AVX-only CPUs --- runtime/simd_fp.h | 69 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/runtime/simd_fp.h b/runtime/simd_fp.h index 7020c254..5593bb5b 100644 --- a/runtime/simd_fp.h +++ b/runtime/simd_fp.h @@ -59,16 +59,60 @@ SN T FN(nan3)(Cpu *c, T a, T b, T d) { return FN(fv)(DNAN); } +// ---- FMA flavours. FMA_S / FMA_V: fast paths, host MXCSR is round-to-nearest there (simd_directed() is checked first). +// FMA_X: scalar FMA for the slow paths, any host rounding mode. +// Without hardware FMA (Ivy Bridge, no -mfma) clang turns __builtin_elementwise_fma into a call to the C runtime's +// fmaf/fma for every lane, which is very slow and keeps the thread outside generated code. For float the product of +// two floats is exact in a double (24 x 24 = 48 bits), so the fused op is done in double. A plain +// (float)(x * y + z) rounds twice and can differ by one ulp; rounding the double sum to odd first (Boldo-Melquiond) +// makes the final float rounding exact: TwoSum gives the error e of s = fl(p + z), and when e != 0 and s has an even +// last bit, s is moved one ulp toward the exact value (the odd neighbour). Inf/NaN lanes are left alone (e is NaN +// there, so the ordered compares below are false). Double has no wider type: it keeps the C runtime's fma. +#if defined(__FMA__) || defined(__aarch64__) || NB == 64 +#define FMA_S(x, y, z) __builtin_elementwise_fma(x, y, z) +#define FMA_V(x, y, z) __builtin_elementwise_fma(x, y, z) +#define FMA_X(x, y, z) __builtin_elementwise_fma(x, y, z) +#else +typedef double simd_vf64x4 __attribute__((vector_size(32))); +typedef uint64_t simd_vu64x4 __attribute__((vector_size(32))); +SI float fma_rn_s(float x, float y, float z) { + double p = (double)x * (double)y, zd = (double)z, s = p + zd; + double bb = s - p, e = (p - (s - bb)) + (zd - bb); // TwoSum: s + e == p + zd exactly + uint64_t u; + memcpy(&u, &s, sizeof u); + if (((e < 0.0) | (e > 0.0)) && !(u & 1)) u += (e > 0.0) == (s > 0.0) ? 1 : (uint64_t)-1; + memcpy(&s, &u, sizeof s); + return (float)s; +} +SI vf32 fma_rn_v(vf32 x, vf32 y, vf32 z) { + simd_vf64x4 p = CVT(x, simd_vf64x4) * CVT(y, simd_vf64x4), zd = CVT(z, simd_vf64x4), s = p + zd; + simd_vf64x4 bb = s - p, e = (p - (s - bb)) + (zd - bb); + simd_vu64x4 u = (simd_vu64x4)s; + simd_vu64x4 fix = (simd_vu64x4)((e < 0.0) | (e > 0.0)) & (simd_vu64x4)((u & 1) == 0); + simd_vu64x4 up = (simd_vu64x4)((e > 0.0) == (s > 0.0)); + u += fix & ((up & 1) | ~up); // +1 toward the exact value, i.e. +1 if e and s share a sign, else -1 + return CVT((simd_vf64x4)u, vf32); +} +// Any rounding mode: directed modes are exact with a plain double (same-direction double rounding is harmless). +SI float fma_any_s(float x, float y, float z) { + if (__builtin_expect(_mm_getcsr() & 0x6000u, 0)) return (float)((double)x * (double)y + (double)z); + return fma_rn_s(x, y, z); +} +#define FMA_S(x, y, z) fma_rn_s(x, y, z) +#define FMA_V(x, y, z) fma_rn_v(x, y, z) +#define FMA_X(x, y, z) fma_any_s(x, y, z) +#endif + // The arithmetic itself, for scalars (KK = K) and vectors (KK = VK). MADD: a + b*d (a = addend). // RECPS/RSQRTS get their first operand already negated (FPNeg happens before NaN processing). -#define FCALC(op, a, b, d, KK) \ +#define FCALC(op, a, b, d, KK, FMA) \ ((op) == FOP_ADD ? (a) + (b) : (op) == FOP_SUB ? (a) - (b) \ : (op) == FOP_MUL || (op) == FOP_MULX ? (a) * (b) : (op) == FOP_NMUL ? -((a) * (b)) \ : (op) == FOP_DIV ? (a) / (b) : (op) == FOP_ABD ? __builtin_elementwise_abs((a) - (b)) \ : (op) == FOP_SQRT ? __builtin_elementwise_sqrt(a) \ - : (op) == FOP_MADD ? __builtin_elementwise_fma(b, d, a) \ - : (op) == FOP_RECPS ? __builtin_elementwise_fma(a, b, KK(2)) \ - : (op) == FOP_RSQRTS ? __builtin_elementwise_fma(a, b, KK(3)) * KK(0.5) : (a)) + : (op) == FOP_MADD ? FMA(b, d, a) \ + : (op) == FOP_RECPS ? FMA(a, b, KK(2)) \ + : (op) == FOP_RSQRTS ? FMA(a, b, KK(3)) * KK(0.5) : (a)) // Lanes whose fast result needs fixing: NaN results (NaN operand or invalid op), RSQRTS overflow. #define FBAD(op, r) ((op) == FOP_RSQRTS ? ((r) != (r)) | (__builtin_elementwise_abs(r) == K(INFINITY)) : (r) != (r)) @@ -76,9 +120,9 @@ SN T FN(nan3)(Cpu *c, T a, T b, T d) { // (3 + a*b) / 2 with one rounding (a already negated): halve a normal operand first, so a product beyond // the format's range (which the fast path rounds to inf, or to the largest normal when rounding down) is exact. SN T FN(rsqrts)(T a, T b) { - if (__builtin_elementwise_abs(a) >= FN(fv)(2 * MINN)) return __builtin_elementwise_fma(a * K(0.5), b, K(1.5)); - if (__builtin_elementwise_abs(b) >= FN(fv)(2 * MINN)) return __builtin_elementwise_fma(a, b * K(0.5), K(1.5)); - return __builtin_elementwise_fma(a, b, K(3)) * K(0.5); + if (__builtin_elementwise_abs(a) >= FN(fv)(2 * MINN)) return FMA_X(a * K(0.5), b, K(1.5)); + if (__builtin_elementwise_abs(b) >= FN(fv)(2 * MINN)) return FMA_X(a, b * K(0.5), K(1.5)); + return FMA_X(a, b, K(3)) * K(0.5); } // ARM result for a lane flagged by FBAD (operands already flushed). @@ -136,13 +180,13 @@ SN T FN(slow)(Cpu *c, int op, T a, T b, T d) { if (FMINMAX(op)) return FN(minmax)(c, op, a, b); unsigned old = simd_rm_enter(fpcr); volatile T va = a, vb = b, vd = d; - volatile T vr = op == FOP_RSQRTS ? FN(rsqrts)(va, vb) : FCALC(op, va, vb, vd, K); + volatile T vr = op == FOP_RSQRTS ? FN(rsqrts)(va, vb) : FCALC(op, va, vb, vd, K, FMA_X); T r = vr; if (FBAD(op, r)) vr = FN(fix)(c, op, a, b, d), r = vr; if ((fpcr & FPCR_FZ) && (FN(fb)(r) & ~SGN) <= MINN && !FN(iszero)(FN(fb)(r))) { if ((FN(fb)(r) & ~SGN) == MINN) { simd_rm_rz(); - volatile T vz = FCALC(op, va, vb, vd, K); + volatile T vz = FCALC(op, va, vb, vd, K, FMA_X); if ((FN(fb)(vz) & ~SGN) < MINN) r = FN(fv)(FN(fb)(r) & SGN); } else { r = FN(fv)(FN(fb)(r) & SGN); @@ -172,7 +216,7 @@ SI T FN(op)(Cpu *c, int op, T a, T b, T d) { if (c->fpcr & FPCR_FZ) a = FN(flush)(a), b = FN(flush)(b); return FN(minmax)(c, op, a, b); } - T r = FCALC(op, a, b, d, K); + T r = FCALC(op, a, b, d, K, FMA_S); if (__builtin_expect(op == FOP_RSQRTS ? FREDO(op, r) : !FOK(r), 0)) r = FN(redo)(c, op, a, b, d, r); return r; } @@ -207,7 +251,7 @@ SI VT FN(vop)(Cpu *c, int op, VT a, VT b, VT d) { r = (VT)FN(vsel)(a < b, x, FN(vsel)(a == b, x | y, y)); return r; } - r = FCALC(op, a, b, d, VK); + r = FCALC(op, a, b, d, VK, FMA_V); if (__builtin_expect(op == FOP_RSQRTS ? __builtin_reduce_or(FREDO(op, r)) != 0 : __builtin_reduce_and(FOK(r)) == 0, 0)) return FN(vslow)(c, op, a, b, d); return r; @@ -363,3 +407,6 @@ SI T FN(recpx)(Cpu *c, T a) { #undef MINN #undef VK #undef K +#undef FMA_S +#undef FMA_V +#undef FMA_X From eb5e6e698e59c7a7ff81fd57ec972c6fe100e7be Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 03:10:24 +0300 Subject: [PATCH 11/39] Accept amiibo dumps by the amiibo capability container F1 10 FF EE The loader asked for E1, the container byte of a plain NDEF tag, so it refused every real amiibo dump as "Not an amiibo dump". --probe-services now loads a dump with an amiibo header and refuses the same file with E1. Co-Authored-By: Claude Opus 5.5 --- runtime/amiibo.c | 7 ++++--- runtime/ov_probe.c | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/runtime/amiibo.c b/runtime/amiibo.c index 07f30031..8b2c6a95 100644 --- a/runtime/amiibo.c +++ b/runtime/amiibo.c @@ -29,9 +29,10 @@ static bool amiibo_validate_dump(const uint8_t *data, uint64_t size) { return false; } - // NTAG215 Capability Container (CC) at page 3 (offset 0x0C). - // Byte 0x0C must be 0xE1 (NDEF Magic / Type 2 Tag CC container byte). - if (data[0x0C] != 0xE1) { + // NTAG215 Capability Container (CC) at page 3 (offset 0x0C). An amiibo's is F1 10 FF EE, not the E1 of a plain + // NDEF tag (3dbrew, "Amiibo"); the CC is not encrypted, so encrypted dumps carry it too. + static const uint8_t amiibo_cc[4] = {0xF1, 0x10, 0xFF, 0xEE}; + if (memcmp(data + 0x0C, amiibo_cc, sizeof amiibo_cc)) { return false; } diff --git a/runtime/ov_probe.c b/runtime/ov_probe.c index 42bbb52b..470e9b92 100644 --- a/runtime/ov_probe.c +++ b/runtime/ov_probe.c @@ -12,6 +12,7 @@ #include #include +#include "amiibo.h" #include "cfg.h" #include "fs.h" #include "gfx.h" @@ -398,6 +399,18 @@ static void probe_applet(void) { int reattached = event_works(activate, 0) && event_works(deactivate, 0); CHECK(reattached, "the nfp events do not work after DestroySystemEvent and a second Attach"); CHECK((uint32_t)CALL("nfp.StartDetection", U(handles)) == 0, "nfp StartDetection again"); + // An amiibo's NTAG215 header: UID with its two check bytes, static lock 0F E0, capability container F1 10 FF EE. + // The loader takes it and refuses the same file with a plain NDEF container (E1). + static const uint8_t head[16] = {0x04, 0x11, 0x22, 0x88 ^ 0x04 ^ 0x11 ^ 0x22, 0x33, 0x44, 0x55, 0x66, + 0x33 ^ 0x44 ^ 0x55 ^ 0x66, 0x48, 0x0F, 0xE0, 0xF1, 0x10, 0xFF, 0xEE}; + uint8_t dump[AMIIBO_DUMP_SIZE] = {0}; + memcpy(dump, head, sizeof head); + const char *dump_path = "probe-amiibo.bin"; + int amiibo_ok = plat_file_write(dump_path, dump, sizeof dump) && amiibo_load_file(dump_path); + dump[0x0C] = 0xE1; + amiibo_ok = amiibo_ok && plat_file_write(dump_path, dump, sizeof dump) && !amiibo_load_file(dump_path); + plat_fs_unlink(dump_path); + CHECK(amiibo_ok, "the amiibo loader refuses a dump with the amiibo capability container, or takes a plain NDEF one"); CHECK(CALL("nfp.Finalize", 0) == 0 && (uint32_t)CALL("nfp.GetState", 0) == 0, "nfp finalize"); CALL("err.ShowError", 0x2a8); CALL("nndiagStartup", 0); From d22a19c91df61c84c86e9902c90940c7434e1fbd Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 03:16:58 +0300 Subject: [PATCH 12/39] Give the reverb delay lines one ring length for cursor and taps so no tap reads an unwritten slot (#421) Co-Authored-By: Claude Sonnet 5.5 --- runtime/audio_effects.c | 11 +++++++---- runtime/audio_reverb.c | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/runtime/audio_effects.c b/runtime/audio_effects.c index 2e030a4a..d3768ef6 100644 --- a/runtime/audio_effects.c +++ b/runtime/audio_effects.c @@ -22,8 +22,11 @@ struct AudioEffectDsp { }; static int channel_ok(unsigned n) { return n==1 || n==2 || n==4 || n==6; } static unsigned sample_count(unsigned rate, float ms) { return (unsigned)nearbyintf(rate*ms); } -/* The reference DelayLine wraps at CurrentSampleCount-1, including when its - backing allocation is larger. Preserve this timing for sample comparisons. */ +/* The ring has `count` slots. The reference DelayLine wraps its cursor at + CurrentSampleCount-1 while its taps index modulo CurrentSampleCount, so once + per turn every tap reads a slot that is never written (a one-sample dropout + per tap, audible as grain). The cursor and the taps share one ring length here; + each delay is one sample longer than the reference. */ static void line_set(DelayLine *d, unsigned n) { d->count=nmax?n:d->max; d->at=0; } static void line_init(DelayLine *d, unsigned rate, float ms) { d->max=sample_count(rate,ms); d->data=calloc((size_t)d->max+1,sizeof(float)); @@ -32,12 +35,12 @@ static void line_init(DelayLine *d, unsigned rate, float ms) { static float line_read(const DelayLine *d) { return d->data[d->at]; } static float line_update(DelayLine *d,float v) { float old=line_read(d);d->data[d->at++]=v; - if(d->at >= (d->count?d->count-1:0))d->at=0; + if(d->at >= d->count)d->at=0; return old; } static float line_tap(const DelayLine *d,unsigned sample,int offset) { int at=(int)d->at-(int)sample-offset; - if(at<0)at+=(int)d->count; + if(d->count){at%=(int)d->count;if(at<0)at+=(int)d->count;} if(at<0 || (unsigned)at>d->max)rt_fatal("audio effect delay tap bounds"); return d->data[at]; } diff --git a/runtime/audio_reverb.c b/runtime/audio_reverb.c index aef08cfd..d477e54c 100644 --- a/runtime/audio_reverb.c +++ b/runtime/audio_reverb.c @@ -24,8 +24,8 @@ static void delay_set(Delay *d, unsigned n) { if (n > d->cap) rt_fatal("reverb d static void delay_init(Delay *d, unsigned khz, float ms) { d->cap = samples(khz, ms); d->data = calloc(d->cap + 1, sizeof(float)); if (!d->data) rt_fatal("reverb delay allocation"); delay_set(d, d->cap); } static float delay_update(Delay *d, float v) { d->data[d->write++] = v; float out = d->data[d->read++]; - if (d->read >= d->cap) d->read = 0; - if (d->write >= d->cap) d->write = 0; + if (d->read > d->cap) d->read = 0; + if (d->write > d->cap) d->write = 0; return out; } static float delay_tap(const Delay *d, unsigned at) { int i = (int)d->write - (int)at - 1; if (i < 0) i += (int)d->cap + 1; return d->data[i]; } From b44caca695b16f70be5b1decb18f811858052e02 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 03:29:33 +0300 Subject: [PATCH 13/39] Count a voice's consumed samples with the same accumulation the mixer steps by, so a frame never skips a source sample (#426) advance_voice computed fraction + seconds * rate * pitch while voice_render adds rate * pitch / 48000 per output sample. The two rounded apart on most frames at 32 kHz and 44.1 kHz, so the voice jumped one sample ahead of what was rendered. audio_voice_consumed now runs the mixer's own loop. TOTK_AUDIO_DUMP= writes the sink mix before the clamp and logs how many frames were rendered under 1 ms after the previous one; a one-time log line reports the first enabled AUX effect. Co-Authored-By: Claude Sonnet 5.5 --- runtime/audio_mixer.c | 51 ++++++++++++++++++++++++++++++++++++++++++- runtime/audren.c | 8 +++---- runtime/audren.h | 1 + 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/runtime/audio_mixer.c b/runtime/audio_mixer.c index 6dc625b8..4c212e9c 100644 --- a/runtime/audio_mixer.c +++ b/runtime/audio_mixer.c @@ -232,6 +232,34 @@ static void output_push(Mixer *m, const float *lr, int frames) { if (!m->device_ok) atomic_store(&m->rd, wr + (uint64_t)frames); // no device: the ring only keeps the latest frames } +// TOTK_AUDIO_DUMP=: the sink mix before the clamp as raw interleaved float32 stereo, and every 5 s one log line with +// how many frames were rendered less than 1 ms after the one before (a burst refill). File writes on the render thread: +// for diagnosis only. +static void sink_dump(const float *raw, int frames) { + static FILE *f; + static int opened; + static uint64_t last, window_start, total, bursts; + if (!opened) { + opened = 1; + const char *path = cfg_get("TOTK_AUDIO_DUMP"); + if (path && *path) f = fopen(path, "wb"); + } + if (!f) return; + fwrite(raw, sizeof(float), (size_t)frames * 2, f); + uint64_t now = kern_now_ns(); + total++; + if (last && now - last < 1000000ull) bursts++; + last = now; + if (!window_start) window_start = now; + if (now - window_start >= 5000000000ull) { + rt_log("audio: dump: %llu frames in %.1f s, %llu rendered under 1 ms after the previous", (unsigned long long)total, + (now - window_start) / 1e9, (unsigned long long)bursts); + total = bursts = 0; + window_start = now; + fflush(f); + } +} + // Level meter (TOTK_TRACE=audio): one line per 5 s of output, and the first nonzero frame. static void level_note(AudioRenderer *r, Mixer *m, const float *lr, int frames) { if (!(rt_trace & RT_TRACE_AUDIO)) return; @@ -505,8 +533,25 @@ static void walk_step(Walk *w) { /* the same transitions as advance_voice in a walk_skip_empty(w); } /* Decodes `frames` output samples per channel into out[c][k]; the renderer advances the voice afterwards by the same amount. */ +static double voice_step(const AudioRenderer *r, const ServerVoice *v) { + return v->in.sample_rate * (v->in.pitch > 0 ? v->in.pitch : 1.0) / r->param.sample_rate; +} +/* Source samples the voice consumes over `frames` output samples, counted with the exact accumulation voice_render steps by + (float rounding makes `fraction + seconds * rate * pitch` differ from it by one sample on some frames, a seam click); + leaves the fractional position in v->fraction. */ +uint64_t audio_voice_consumed(const AudioRenderer *r, ServerVoice *v, int frames) { + const double step = voice_step(r, v); + double frac = v->fraction; + uint64_t consumed = 0; + for (int k = 0; k < frames; k++) { + frac += step; + while (frac >= 1.0) { frac -= 1.0; consumed++; } + } + v->fraction = frac; + return consumed; +} static void voice_render(AudioRenderer *r, ServerVoice *v, float out[6][MAX_FRAME], int frames) { - const double step = v->in.sample_rate * (v->in.pitch > 0 ? v->in.pitch : 1.0) / r->param.sample_rate; + const double step = voice_step(r, v); Walk w = {v, v->index, v->offset, v->loops_done, {0}, 0, v->adpcm.history[0], v->adpcm.history[1], v->adpcm.buffer, v->adpcm.at}; memcpy(w.valid, v->valid, sizeof w.valid); walk_skip_empty(&w); @@ -545,6 +590,7 @@ static void mix_aux(AudioRenderer *r, int mix, const AudrenEffectIn *e) { uint8_t *send=ipc_guest(p.send_info,128+(uint64_t)p.capacity*4), *ret=ipc_guest(p.return_info,128+(uint64_t)p.capacity*4); uint32_t *sh=(uint32_t *)(send+64),*rh=(uint32_t *)(ret+64); int32_t *sd=(int32_t *)(send+128),*rd=(int32_t *)(ret+128); uint32_t wr=sh[1],read=rh[0],total=p.channels*(uint32_t)m->frame; + if(e->enabled) report_once(m,6,"auxiliary effect in use, channels",(int)p.channels); for(unsigned c=0;c=r->mixes[mix].buffer_count || p.output[c]>=r->mixes[mix].buffer_count)rt_fatal("auxiliary effect mix buffer"); float *input=mixbuf(m,m->offset[mix]+p.input[c]),*output=mixbuf(m,m->offset[mix]+p.output[c]); @@ -798,6 +844,7 @@ void audio_mixer_render(AudioRenderer *r, double seconds) { if (sink && sink->input_count >= 1 && sink->input_count <= 6) { const float gain = host_volume() * atomic_load(&device_volume) * (r->mixes[0].in_use ? r->mixes[0].volume : 1.0f); const float *ch[6] = {0}; + float raw[2 * MAX_FRAME]; for (uint32_t c = 0; c < sink->input_count; c++) if (sink->inputs[c] >= 0 && sink->inputs[c] < m->nbuf) ch[c] = mixbuf(m, sink->inputs[c]); for (int k = 0; k < frames; k++) { float l, rr; @@ -809,8 +856,10 @@ void audio_mixer_render(AudioRenderer *r, double seconds) { } else if (sink->input_count >= 2) { l = ch[0] ? ch[0][k] : 0; rr = ch[1] ? ch[1][k] : 0; } else l = rr = ch[0] ? ch[0][k] : 0; l *= gain; rr *= gain; + raw[2 * k] = l; raw[2 * k + 1] = rr; lr[2 * k] = l > 1 ? 1 : l < -1 ? -1 : l; lr[2 * k + 1] = rr > 1 ? 1 : rr < -1 ? -1 : rr; } + sink_dump(raw, frames); } else if (!sink && !circular) report_once(m, 5, "no device sink in use", r->param.sink_count); level_note(r, m, lr, frames); output_push(m, lr, frames); diff --git a/runtime/audren.c b/runtime/audren.c index 61d3c609..2b3ca42e 100644 --- a/runtime/audren.c +++ b/runtime/audren.c @@ -318,11 +318,9 @@ static void request_update(AudioRenderer *r, const uint8_t *input, uint64_t in_s // ---- render tick: one frame each time the output device has taken the ring below its target (audio_mixer_pace_ns), // or every sample_count / sample_rate seconds of the host clock without a device, while stopped or while it stalls -static void advance_voice(AudioRenderer *r, ServerVoice *v, double out_seconds) { +static void advance_voice(AudioRenderer *r, ServerVoice *v) { if (!v->in.in_use || v->state != SV_STARTED || !v->in.sample_rate) return; - double want = v->fraction + out_seconds * v->in.sample_rate * (v->in.pitch > 0 ? v->in.pitch : 1.0); - uint64_t samples = (uint64_t)want; - v->fraction = want - (double)samples; + uint64_t samples = audio_voice_consumed(r, v, (int)r->param.sample_count); while (samples && v->valid[v->index]) { AudrenWaveBuffer *b = &v->buffers[v->index]; uint64_t length = (uint64_t)(b->end - b->start), left = length - v->offset; @@ -373,7 +371,7 @@ static void render_thread(void *p) { if (r->state == 0) { voices_prepare(r); audio_mixer_render(r, frame); - for (int i = 0; i < r->param.voice_count; i++) advance_voice(r, &r->voices[i], frame); + for (int i = 0; i < r->param.voice_count; i++) advance_voice(r, &r->voices[i]); r->elapsed_frames++; uint64_t given = r->stop_released + r->empty_played + r->flushed; if ((rt_trace & RT_TRACE_IPC) && given != r->logged_released && r->elapsed_frames % 1000 == 0) { diff --git a/runtime/audren.h b/runtime/audren.h index 5d6f0a43..b2fccba0 100644 --- a/runtime/audren.h +++ b/runtime/audren.h @@ -184,5 +184,6 @@ void audio_mixer_render(AudioRenderer *r, double seconds); // after audio_voic // many ns (the ring is at its target), -1 leaves the pace to the render thread's timer (no device, renderer stopped, // device stalled). int64_t audio_mixer_pace_ns(AudioRenderer *r, uint64_t now); +uint64_t audio_voice_consumed(const AudioRenderer *r, ServerVoice *v, int frames); // source samples over one frame; advances v->fraction int audio_buffer_empty(const AudrenWaveBuffer *b); // no samples to decode: played as soon as it is reached void audio_device_handler(IpcSession *s, const IpcRequest *rq, IpcResponse *rs); // IAudioDevice From 3de08398bf521fc5a2dbaa02960dbe601ee8056a Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 03:36:38 +0300 Subject: [PATCH 14/39] Size the scene by the DLSS mode only while DLSS/DLAA is on With dlaa = 0 and dlss = quality left from the menu, the scene still rendered at 67 % and the plain upscaler stretched it, so the picture looked blurrier with DLSS switched off. Co-Authored-By: Claude Opus 5.5 --- runtime/resolution.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runtime/resolution.c b/runtime/resolution.c index 060006b7..d1941300 100644 --- a/runtime/resolution.c +++ b/runtime/resolution.c @@ -71,10 +71,12 @@ static void decide(void) { } const char *s = cfg_value("TOTK_RENDER_SCALE"); double k = 5.0 / 6.0; - const char *m = cfg_value("TOTK_DLSS"); // a DLSS mode other than dlaa sets the scene size + // a DLSS mode other than dlaa sets the scene size, only while DLSS/DLAA is on: switched off, the mode the menu + // still holds would shrink the scene for the plain upscaler + const char *m = cfg_value("TOTK_DLSS"); static const struct { const char *name; double k; } dlss[] = { {"quality", 2.0 / 3.0}, {"balanced", 0.58}, {"performance", 0.5}}; - for (int i = 0; i < 3; i++) + for (int i = 0; cfg_bool("TOTK_DLAA") && i < 3; i++) if (!strcmp(m, dlss[i].name)) R.dlss_k = dlss[i].k; if (R.dlss_k) { k = R.dlss_k; From 984c9bc7c3963bf46ce4576121a1724dd43f835d Mon Sep 17 00:00:00 2001 From: Michael Anthony Bitoon Date: Sun, 1 Nov 2026 20:19:00 +0800 Subject: [PATCH 15/39] Read the gradients of a TXD per coordinate so terrain picks the right mip level TotK's terrain shaders fill the gradient registers of a texture-with-derivatives instruction per coordinate (du/dx, du/dy, dv/dx, dv/dy), while the translator takes the first two registers as dPdx and the last two as dPdy. The mip level then came from the wrong derivatives and was too sharp on terrain faces turned across the screen, so patches that followed the faces crawled when the camera moved. After a shader loads, every two-dimensional gradient pair has its inner components swapped by two new composite constructs, so the shader cache is untouched. TOTK_TXD_TRANSPOSE=0 turns it off. The finding is row 2.13 of docs/maxwell.md. --- docs/maxwell.md | 1 + runtime/cfg.c | 2 +- runtime/gfx_shader.c | 62 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/maxwell.md b/docs/maxwell.md index 472360f7..3d7623f7 100644 --- a/docs/maxwell.md +++ b/docs/maxwell.md @@ -68,6 +68,7 @@ Source shorthands: | 2.9 | **LOD**: TSC min/max LOD clamp, LOD bias, `TWO_D_NO_MIPMAP` texture type, a min filter without mips stays on the base level; TIC carries its own max anisotropy and aniso quality fields. | `minLod`/`maxLod`, `mipLodBias` limited by `maxSamplerLodBias`. | yuzu: anisotropy heuristics for mods forcing trilinear (YZ-2306, PR 10744). Ours: non-mip filter → `maxLod = minLod + 0.25`, bias clamped (`vk_texture.c` `make_sampler`). | G80TEX | | 2.10 | **Texture gather rounding**: Maxwell sub-texel precision differs from AMD/Intel; edge texels picked by TLD4 differ by one. | Implementation-defined `subTexelPrecisionBits`. | yuzu adds a sub-pixel offset to gathers on non-NVIDIA hosts: fixed BotW shadow artifacts at object edges ("shader_recompiler: Add subpixel offset for correct rounding at ImageGather", , title only). Ours: the translator biases gather coordinates by the host's `subTexelPrecisionBits` on every non-NVIDIA GPU (`vk_caps.c` → job `gather_bias` → Ryujinx `TexturePass.InsertCoordGatherBias`); TotK on RADV drew dashed dark lines along wall and floor edges in the prologue caves without it. `TOTK_GATHER_BIAS=0` turns it off. | — | | 2.11 | **Gather offsets**: `TLD4.PTP` takes four per-texel offsets, possibly from registers. | `ConstOffsets` must be constant. | Ours: `QueryHostSupportsNonConstantTextureOffset() => false` makes Ryujinx emulate register offsets (`tools/xlate/Program.cs`). | RYU-SH `Translation/Optimizations` (TexturePass) | +| 2.13 | **TXD gradient register order**: TotK's terrain shaders fill the gradient registers of a `TXD` per coordinate (du/dx, du/dy, dv/dx, dv/dy). | Vulkan's `Grad` takes dPdx (du/dx, dv/dx) and dPdy (du/dy, dv/dy). | Ryujinx's `EmitTxd` takes the first two registers as dPdx and the last two as dPdy, so the mip level came from the wrong derivatives and was too sharp on terrain faces turned across the screen: patches that followed the faces crawled when the camera moved (1.4.3, the Depths; found by the user from their F9 frame, a gradient sample's coordinate and gradients traced back to `DPdx` and `DPdy` of the interpolants). Ours: `gfx_shader.c` `patch_txd_transpose` swaps the two inner components of every two-component gradient pair in the loaded SPIR-V (the shader cache is untouched). `TOTK_TXD_TRANSPOSE=0` turns it off. Only 2D gradients are handled; 3D and cube ones are left as translated. | — | | 2.12 | **Depth compare** (`TSC` depth compare + `TIC_4_DEPTH_TEXTURE`), min/max reduction filtering. | `compareEnable`; reduction mode needs `samplerFilterMinmax` and is not allowed with compare. | Ours: reduction dropped when compare is on (`vk_texture.c` `make_sampler`). | G80TEX | | 2.13 | **ASTC / BC**: native ASTC; zero ASTC block = error colour on hosts. | AMD and NVIDIA desktop GPUs lack ASTC: decode to RGBA8. yuzu offered ASTC recompression to BC1/BC3 for low VRAM (YZ-2305, PR 10398). | Ours: native upload where the device samples ASTC, else a compute-shader decode (`vk_astc.c`, `TOTK_ASTC`), CPU decode (`tex_astc.c`) as the fallback. | `runtime/vk_astc.c` | | 2.14 | **Linear CPU-written textures** (`FLAGS_LINEAR`, small ramps, glyph caches): CPU writes visible to the GPU directly. | Must re-upload or use linear images over host memory. | NXN Mario: sky black without its 64×1 RGBA16F ramps. | NXN | diff --git a/runtime/cfg.c b/runtime/cfg.c index 015cda1b..91d07f57 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -53,7 +53,7 @@ static const char *const known[] = { "SHADER_MEMO", "SHADOW_RANGE", "SHADOW_RES", "SHADOW_SCALE", "SHARED_FONTS", "SHARPNESS", "SHOTS", "SHOTS_DIR", "SHOTS_FULL", "SPEED_PROBE", "SPIN", "SPIN_HANDOFF_US", "SPLIT_DRAWS", "SPLIT_US", "STICK_DEADZONE", "STICK_SENS", "SWKBD_ANSWER", - "SWKBD_TEST", "TEST_PAD", "TEST_PREPARE_SHADERS", "TEX_ALIAS", "TEX_MEMO", "TEX_VRAM_PRESSURE", "TIMER_1MS", "TRACE", "UBO_SNAPSHOT", "UPSCALER", "VK_DYN_A2C", "VK_DYN_VERTEX", "VK_NO_FLOAT64", + "SWKBD_TEST", "TEST_PAD", "TEST_PREPARE_SHADERS", "TEX_ALIAS", "TEX_MEMO", "TEX_VRAM_PRESSURE", "TIMER_1MS", "TRACE", "TXD_TRANSPOSE", "UBO_SNAPSHOT", "UPSCALER", "VK_DYN_A2C", "VK_DYN_VERTEX", "VK_NO_FLOAT64", "VK_PRESENT", "VK_RECORDER", "VK_SSBO_ALIGN", "VK_SUBGROUP", "VK_SUBGROUP_PIN", "VK_VALIDATION", "VOLUME", "VRAM_HEADROOM", "VS_OUTS", "WALK_KEY", "WALK_SCALE", "WB_EXACT", "WB_FENCE_LIMIT", "WINDOW_SIZE", "XLATE", "XLATE_ID", "XLATE_PROCS", "ZSTD_CHECK", "ZSTD_NATIVE", diff --git a/runtime/gfx_shader.c b/runtime/gfx_shader.c index 3388935b..09419be3 100644 --- a/runtime/gfx_shader.c +++ b/runtime/gfx_shader.c @@ -759,6 +759,67 @@ out: free(loc), free(packed), free(ttype), free(tcomps), free(pointee); } +// Every gradient sample (OpImageSampleExplicitLod with Grad) whose two gradients are each built from two components gets +// them transposed, dx = (a, c) and dy = (b, d) from dx = (a, b) and dy = (c, d), by two new OpCompositeConstruct before +// it. TotK's shaders fill a TXD's gradient registers per coordinate (du/dx, du/dy, dv/dx, dv/dy), and the translator +// (Ryujinx, InstEmitTexture EmitTxd) builds dx = (first two registers), dy = (last two) = (du/dx, du/dy) and +// (dv/dx, dv/dy): a mip level chosen from the wrong derivatives, too sharp on terrain faces turned across the screen, +// which crawled in patches that followed the faces (1.4.3, the Depths). TOTK_TXD_TRANSPOSE=0 turns it off. +// Applied to the SPIR-V after it is loaded, so the shader cache is untouched. +enum { OP_COMPOSITE_CONSTRUCT = 80, OP_SAMPLE_EXPLICIT = 88, IO_GRAD = 4 }; + +static void patch_txd_transpose(GfxShader *s) { + static int on = -1; + static volatile int32_t n_logged; + if (on < 0) { + const char *e = cfg_get("TOTK_TXD_TRANSPOSE"); + on = !(e && *e == '0'); + } + uint32_t *w = s->spirv; + uint64_t n = s->words; + if (!on || n < 5) return; + uint32_t bound = w[3] < (1u << 20) ? w[3] : (1u << 20); + uint64_t *cc = calloc(bound, sizeof *cc); // word index of a 2-constituent OpCompositeConstruct defining the id + int found = 0; + if (!cc) return; + for (uint64_t i = 5; i < n;) { + uint32_t op = w[i] & 0xffff, len = w[i] >> 16; + if (!len || i + len > n) goto out; + if (op == OP_COMPOSITE_CONSTRUCT && len == 5 && w[i + 2] < bound) cc[w[i + 2]] = i; + if (op == OP_SAMPLE_EXPLICIT && len >= 8 && w[i + 5] == IO_GRAD && w[i + 6] < bound && w[i + 7] < bound && + cc[w[i + 6]] && cc[w[i + 7]] && w[cc[w[i + 6]] + 1] == w[cc[w[i + 7]] + 1]) + found++; + i += len; + } + if (!found) goto out; + uint32_t *o = malloc((n + (uint64_t)found * 10) * sizeof *o); + if (!o) goto out; + uint64_t k = 0; + uint32_t next = w[3]; + for (uint64_t i = 0; i < 5; i++) o[k++] = w[i]; + for (uint64_t i = 5; i < n;) { + uint32_t op = w[i] & 0xffff, len = w[i] >> 16; + if (op == OP_SAMPLE_EXPLICIT && len >= 8 && w[i + 5] == IO_GRAD && w[i + 6] < bound && w[i + 7] < bound && + cc[w[i + 6]] && cc[w[i + 7]] && w[cc[w[i + 6]] + 1] == w[cc[w[i + 7]] + 1]) { + const uint32_t *dx = w + cc[w[i + 6]], *dy = w + cc[w[i + 7]]; // [hdr, type, id, a, b], [hdr, type, id, c, d] + uint32_t ndx = next++, ndy = next++; + o[k++] = 5u << 16 | OP_COMPOSITE_CONSTRUCT, o[k++] = dx[1], o[k++] = ndx, o[k++] = dx[3], o[k++] = dy[3]; + o[k++] = 5u << 16 | OP_COMPOSITE_CONSTRUCT, o[k++] = dx[1], o[k++] = ndy, o[k++] = dx[4], o[k++] = dy[4]; + for (uint32_t j = 0; j < len; j++) o[k++] = w[i + j]; + o[k - len + 6] = ndx, o[k - len + 7] = ndy; + } else { + for (uint32_t j = 0; j < len; j++) o[k++] = w[i + j]; + } + i += len; + } + o[3] = next; + free(s->spirv); + s->spirv = o, s->words = k; + if (plat_inc32(&n_logged) <= 4) rt_log("gfx_shader: %d gradient samples now read their derivatives per coordinate", found); +out: + free(cc); +} + static void reflect(GfxShader *s) { static const char *const kinds[GFX_RES_KINDS] = {"cbufs", "sbufs", "textures", "images"}; const char *js = s->reflection; @@ -771,6 +832,7 @@ static void reflect(GfxShader *s) { json_objects(js, kinds[k], add_res, s, k); } reflect_spirv(s); + patch_txd_transpose(s); } // ---- use masks (see the cache key at the top) From f4f9d6dd53ce6dafa895d09f911123e9d75f6bcf Mon Sep 17 00:00:00 2001 From: MierzwaMatheus Date: Mon, 2 Nov 2026 01:22:13 -0300 Subject: [PATCH 16/39] Keep the replay's transient buffers and the zero buffer in host-coherent memory The CPU fills these chunks and the zero buffer and never flushes them. On Intel integrated GPUs (ANV on Meteor Lake) the host-cached memory type that VMA picks for random host access is not coherent, so the GPU read what an earlier submission had left there. A lighting fragment shader got another draw's UpdateUniformBuffer payload as its step count (0x3f800000 instead of 96 or 128), looped about a billion times and lost the device; other stale payloads drew the black frames. Requiring HOST_COHERENT keeps the same memory types on GPUs where host memory is already coherent. --- runtime/vk_exec_draw.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/runtime/vk_exec_draw.c b/runtime/vk_exec_draw.c index c8533028..8740ada4 100644 --- a/runtime/vk_exec_draw.c +++ b/runtime/vk_exec_draw.c @@ -234,8 +234,13 @@ static uint8_t *scratch(VkExec *ex, uint64_t size, VkBuffer *buf, VkDeviceSize * .usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT}; + // HOST_COHERENT: the CPU fills these chunks and never flushes them. Intel's integrated GPUs (ANV, Meteor Lake) + // offer host-cached memory only without coherence, which VMA picks for random host access: the GPU then read + // what an earlier submission left in the chunk (an UpdateUniformBuffer payload of another draw: a lighting + // shader's step count of 0x3f800000 instead of 128, and a loop of a billion steps). VmaAllocationCreateInfo ai = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST, - .flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT}; + .flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT, + .requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT}; VmaAllocationInfo info; VkResult res = vmaCreateBuffer(vk_ctx()->vma, &bi, &ai, &c->buf, &c->alloc, &info); if (res != VK_SUCCESS) rt_fatal("vk: transient buffer of %llu bytes failed (%d)", (unsigned long long)n, (int)res); @@ -351,7 +356,8 @@ static void dummies_init(void) { VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT}; VmaAllocationCreateInfo ai = {.usage = VMA_MEMORY_USAGE_AUTO_PREFER_HOST, - .flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT}; + .flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT, + .requiredFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT}; // the memset below is not flushed VmaAllocationInfo info; if (vmaCreateBuffer(c->vma, &bi, &ai, &Z.zbuf, &Z.zalloc, &info) != VK_SUCCESS) rt_fatal("vk: zero buffer failed"); memset(info.pMappedData, 0, ZERO_BYTES); From 5eb0a19ee53068c9ab9d26259a498f38bd7c0382 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 08:49:08 +0300 Subject: [PATCH 17/39] Pin the upstream references at their 2026-11-02 heads LASRecomp, MacNativeGames, d2r-mobile, nxnative and SavageWindRecomp moved; SwitchRecomp and deko3d did not. Co-Authored-By: Claude Opus 5.5 --- tools/fetch_refs.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/fetch_refs.sh b/tools/fetch_refs.sh index 6addaa70..7c6a2f03 100644 --- a/tools/fetch_refs.sh +++ b/tools/fetch_refs.sh @@ -31,10 +31,10 @@ fetch() { echo "$name: $commit" } -fetch LASRecomp https://github.com/peiikai/LASRecomp.git da35b83a9611d046a9a681aae668d7085954d37f -fetch MacNativeGames https://github.com/ly05010419/MacNativeGames.git b75be8cfa704df3701e86130c8ced268e79d6b11 history -fetch d2r-mobile https://github.com/jhaizhou-ops/d2r-mobile.git 53e7c5bb9ca3ea53e2160005be8fbe920cd9cb53 -fetch nxnative https://github.com/seedds/nxnative.git d5b380b3a80495609c2e751bb994911a638935ad +fetch LASRecomp https://github.com/peiikai/LASRecomp.git a6b4f03844ecfb4012f18deff96da8b410aeafcd +fetch MacNativeGames https://github.com/ly05010419/MacNativeGames.git d1509b4b62211265fad5449cf53cc9f13484ecc5 history +fetch d2r-mobile https://github.com/jhaizhou-ops/d2r-mobile.git fae8c680fe98f79179cf3fab85f7a6c14ee28600 +fetch nxnative https://github.com/seedds/nxnative.git 99330eb81c873c3aafe9fc92194c9687d77fcdbb fetch SwitchRecomp https://github.com/bgyss/SwitchRecomp.git 0f99e1c5c1e78fdc8e01e58a0f8dc8622e82976b fetch deko3d https://github.com/devkitPro/deko3d.git 373bfd43d8b2d805fa2eb7d406dbb823032df958 -fetch SavageWindRecomp https://github.com/Revivedx/SavageWindRecomp.git 575ad88e02f9a89a9a885d90039d48af33c982f4 +fetch SavageWindRecomp https://github.com/Revivedx/SavageWindRecomp.git bf6146c14c37fee8781a601e5a544b93dff6d75e From b6ab660b0066375a38a5ab17dd2bee26e50f4e58 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 09:14:10 +0300 Subject: [PATCH 18/39] A zero-byte memory pool initialises as an empty pool in the Vulkan back end vk_pool_init gave a size-0 pool with storage a record: the host import of 0 bytes failed (the driver refuses the memory, VK_ERROR_INVALID_EXTERNAL_HANDLE) and the record went into the pool index as the empty range [mem, mem]. A lookup at that address then found the empty pool before the pool that starts there and reported it unbacked. TotK makes such pools now and then (MacNativeGames d3407e29, 1.2.1). Such a pool now gets no record, as a GPU_NO_ACCESS pool; vk_pool_fini finds none and does nothing. Removal by key already kept a neighbour's range safe at Finalize. --probe-vkexec gains an empty pool part: a zero-byte pool at the start of another, lookups there before and after its Finalize. It failed before ("a lookup at the start of a pool finds the empty pool there"). Co-Authored-By: Claude Opus 5.5 --- runtime/vk_exec_probe.c | 28 ++++++++++++++++++++++++++++ runtime/vk_memory.c | 5 +++++ 2 files changed, 33 insertions(+) diff --git a/runtime/vk_exec_probe.c b/runtime/vk_exec_probe.c index 3663e556..a63fafce 100644 --- a/runtime/vk_exec_probe.c +++ b/runtime/vk_exec_probe.c @@ -845,6 +845,31 @@ static int test_mirror(const GfxBackend *b, uint8_t *guest, int *bytes) { return 0; } +// A pool of zero bytes (TotK makes some now and then; MacNativeGames hit one on 1.2.1) initialises as an empty pool: no +// Vulkan buffer or memory (a buffer or import of 0 bytes is invalid usage) and no pool index entry, so a lookup at its +// address still finds the pool that starts there, before and after the empty pool's Finalize. +static int test_empty_pool(const GfxBackend *b, uint8_t *guest) { + pool_t pool = {.magic = NVN_POOL_MAGIC, .flags = 0x22, .mem = guest + 0xE00000, .size = 0x100000, + .game_mem = guest + 0xE00000}; + pool_t empty = {.magic = NVN_POOL_MAGIC, .flags = 0x22, .mem = guest + 0xE00000, .size = 0, + .game_mem = guest + 0xE00000}; + CHECK(b->pool_init(&pool), "pool_init of a CPU-visible pool for the empty-pool part"); + long errors = vk_validation_errors_total(); + CHECK(b->pool_init(&empty), "pool_init of a zero-byte pool"); + uint64_t a = (uint64_t)(uintptr_t)pool.mem; + VkMemSpan whole, at, after; + CHECK(vk_mem_resolve(a + 16, 16, &whole) && whole.buffer, "the pool beside an empty pool is not resolved"); + CHECK(vk_mem_resolve(a, 0, &at) && at.buffer == whole.buffer && at.offset == whole.offset - 16, + "a lookup at the start of a pool finds the empty pool there"); + CHECK(vk_validation_errors_total() == errors, "the zero-byte pool raised %ld validation errors", + vk_validation_errors_total() - errors); + b->pool_fini(&empty); + CHECK(vk_mem_resolve(a + 16, 16, &after) && after.buffer == whole.buffer, + "the empty pool's Finalize took the pool beside it out of the index"); + b->pool_fini(&pool); + return 0; +} + int probe_vkexec(void) { vk_hdr_off(); // the present checks read 8-bit swapchain pixels layouts_equal = 0; @@ -866,6 +891,7 @@ int probe_vkexec(void) { int nvnx_bytes = 0, mirror_bytes = 0; if (!bad) bad = test_nvnx(b, guest, &nvnx_bytes); if (!bad) bad = test_mirror(b, guest, &mirror_bytes); + if (!bad) bad = test_empty_pool(b, guest); b->finish(); vk_pipeline_shutdown(); // the pipelines and the draw part's stand-ins go before the device vk_exec_draw_shutdown(); @@ -910,5 +936,7 @@ int probe_vkexec(void) { "between submissions mirrored, CPU bytes beside a fill kept, %d bytes compared)\n", mirror_bytes); else printf("probe-vkexec: mirror part skipped (memory mode import)\n"); + printf("probe-vkexec: empty pool part passed (a zero-byte pool at the start of another: no Vulkan objects, lookups " + "there find the other pool before and after its Finalize)\n"); return 0; } diff --git a/runtime/vk_memory.c b/runtime/vk_memory.c index 39ec43e3..cbcbc165 100644 --- a/runtime/vk_memory.c +++ b/runtime/vk_memory.c @@ -245,6 +245,11 @@ int vk_pool_init(pool_t *p) { // virtual pools that map it. Either gets device-local memory: 1.4.3's CPU never writes its 0x18c pool's storage // (unchanged over 3600 frames of the intro), so the guest storage and the GPU copy need no sync. if ((p->flags & NVN_MEMORY_POOL_FLAGS_GPU_NO_ACCESS) && !(p->flags & NVN_MEMORY_POOL_FLAGS_PHYSICAL)) return 1; + // A pool of zero bytes (TotK makes some now and then) holds no address the GPU can use: it gets no record. A record + // would import 0 bytes (invalid usage; the driver refuses the memory) and put an empty range into the index at the + // pool's address, where a lookup of the pool that starts there found the empty one first. vk_pool_fini finds no + // record for it and does nothing. + if (!p->size) return 1; PoolRec *r = calloc(1, sizeof *r); if (!r) rt_fatal("vk: out of memory for a pool record"); r->key = p; From 1f824a6581d81443b1381fc85ca9b081490d0a36 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 09:15:54 +0300 Subject: [PATCH 19/39] WaitSynchronization leaves a pending cancel to the next wait that would block Ours took a pending CancelSynchronization first, before the signalled objects and before the timeout-0 return, so a poll or a wait on a signalled object used it up and the blocking wait it was meant for ran on. Horizon checks a signalled object, then timeout 0, then the cancel (mesosphere KSynchronizationObject::Wait; LAS Recomp 1144aea moved the poll the same way): both keep it pending. A cancel that wakes a blocked wait still ends it cancelled: CancelSynchronization now wakes it with R_CANCELLED, which the loop tells apart from a signal's -1. --probe-kernel events part: a cancel sent before a poll and before a wait on a signalled event; each is followed by a blocking wait with a 500 ms timeout. Before: the poll and the signalled wait returned 0xec01 (cancelled) and the blocking waits timed out (0xea01). After: timeout, success, then cancelled both times. Co-Authored-By: Claude Opus 5.5 --- runtime/kernel.c | 21 ++++++++++++++++++--- runtime/kernel_probe.c | 31 +++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/runtime/kernel.c b/runtime/kernel.c index d438b052..9d3e2700 100644 --- a/runtime/kernel.c +++ b/runtime/kernel.c @@ -1952,8 +1952,13 @@ int kern_svc(Cpu *c, uint32_t imm) { int idx = -1; lock(); g_sync_calls++; + // Horizon's order (mesosphere KSynchronizationObject::Wait): a signalled object, then timeout 0, then a pending + // CancelSynchronization. The cancel is for the next wait that would block: a poll or a wait on a signalled + // object leaves it pending. A cancel that wakes this wait (CancelSynchronization wakes it with R_CANCELLED; a + // signal wakes it with -1 to look again) ends it cancelled, signalled objects or not. + uint32_t woke = 0; for (;;) { - if (t->cancel) { + if (woke == R_CANCELLED) { t->cancel = 0; r = R_CANCELLED; break; @@ -1969,6 +1974,15 @@ int kern_svc(Cpu *c, uint32_t imm) { r = R_OK; break; } + if (ns == 0) { + r = R_TIMEOUT; + break; + } + if (t->cancel) { + t->cancel = 0; + r = R_CANCELLED; + break; + } int64_t left = ns; if (ns > 0) { uint64_t spent = kern_now_ns() - start; @@ -1980,7 +1994,8 @@ int kern_svc(Cpu *c, uint32_t imm) { } t->wobj = n ? hs[0] | (uint64_t)n << 32 : 0; g_sync_blocks++; - if (block(t, W_SYNC, left) == R_TIMEOUT) { + woke = block(t, W_SYNC, left); + if (woke == R_TIMEOUT) { r = R_TIMEOUT; break; } @@ -1995,7 +2010,7 @@ int kern_svc(Cpu *c, uint32_t imm) { RtThread *t = h_get((uint32_t)x[0], O_THREAD); if (t) { t->cancel = 1; - if (t->wkind == W_SYNC) wake(t, (uint32_t)-1); + if (t->wkind == W_SYNC) wake(t, R_CANCELLED); } unlock(); x[0] = t ? R_OK : R_BAD_HANDLE; diff --git a/runtime/kernel_probe.c b/runtime/kernel_probe.c index e226d4a5..2b54d02a 100644 --- a/runtime/kernel_probe.c +++ b/runtime/kernel_probe.c @@ -320,11 +320,17 @@ static void test_arbiter(Cpu *c) { } // ---- events -typedef struct { uint32_t h, r, idx; int64_t timeout; volatile int delay, go; } EvJob; +// pre: a first wait on h0 with timeout t0 (result r0, index idx0) before the wait on h. +typedef struct { uint32_t h, r, idx, h0, r0, idx0; int64_t timeout, t0; volatile int delay, go; int pre; } EvJob; static void event_waiter(Cpu *c) { EvJob *j = arg_p(c, 0); while (j->delay && !j->go) sleep_ms(c, 1); + if (j->pre) { + uint32_t h0[1] = {j->h0}; + j->r0 = sv(c, 0x18, 0, ga(h0), 1, (uint64_t)j->t0); + j->idx0 = (uint32_t)c->x[1]; + } uint32_t hs[1] = {j->h}; j->r = sv(c, 0x18, 0, ga(hs), 1, (uint64_t)j->timeout); j->idx = (uint32_t)c->x[1]; @@ -375,6 +381,26 @@ static void test_events(Cpu *c) { join(c, h); CHECK(g.r == KERN_R_CANCELLED, "wait after a cancel: %#x", g.r); + // A pending cancel is for the next WaitSynchronization that would block (mesosphere KSynchronizationObject::Wait: + // signalled objects first, then timeout 0, then the cancel): a poll times out and a wait on a signalled object + // succeeds, both leaving it pending; the next blocking wait ends cancelled. The blocking waits have a 500 ms + // timeout, so a cancel used up too early shows as a timeout instead of a hang. + CHECK(sv(c, 0x11, hw2, 0, 0, 0) == 0, "SignalEvent of the second event"); + EvJob p = {.h = hr, .timeout = 500000000, .delay = 1, .pre = 1, .h0 = hr, .t0 = 0}; + EvJob q = {.h = hr, .timeout = 500000000, .delay = 1, .pre = 1, .h0 = hr2, .t0 = -1}; + EvJob *pq[2] = {&p, &q}; + for (int i = 0; i < 2; i++) { + h = create(c, a_event_waiter, pq[i], 44, -2); + start(c, h); + CHECK(sv(c, 0x19, h, 0, 0, 0) == 0, "CancelSynchronization before the %s", i ? "signalled wait" : "poll"); + pq[i]->go = 1; + join(c, h); + } + CHECK(p.r0 == KERN_R_TIMEOUT, "poll (timeout 0) with a cancel pending: %#x (want timeout)", p.r0); + CHECK(p.r == KERN_R_CANCELLED, "blocking wait after the poll: %#x (want cancelled)", p.r); + CHECK(q.r0 == 0 && q.idx0 == 0, "wait on a signalled event with a cancel pending: %#x (want success)", q.r0); + CHECK(q.r == KERN_R_CANCELLED, "blocking wait after the signalled wait: %#x (want cancelled)", q.r); + uint32_t sw = kern_event_new(), sr = kern_event_readable(sw); CHECK(wait1(c, sr, 0) == KERN_R_TIMEOUT, "service event before the signal"); kern_event_signal(sw); @@ -382,7 +408,8 @@ static void test_events(Cpu *c) { uint32_t handles[] = {sr, sw, hr2, hw2, hr, hw}; for (unsigned i = 0; i < 6; i++) CHECK(sv(c, 0x16, handles[i], 0, 0, 0) == 0, "CloseHandle %#x", handles[i]); CHECK(sv(c, 0x16, hr, 0, 0, 0) == KERN_R_BAD_HANDLE, "CloseHandle twice"); - printf("events: signal, clear, reset, two handles, cancel while waiting and before, service events, 10 ms " + printf("events: signal, clear, reset, two handles, cancel while waiting and before, a pending cancel kept by a poll " + "and a signalled wait, service events, 10 ms " "timeout after %.2f ms: %s\n", ms, failures == before ? "ok" : "FAIL"); } From 0ab16c21b5c55b2a2a202e91e449c3fd40734444 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 09:15:55 +0300 Subject: [PATCH 20/39] SignalToAddress modify by waiting count subtracts 2 when it wakes all waiters SignalAndModifyByWaitingCountIfEqual with count <= 0 and at least one waiter stored value - 1; Horizon stores value - 2 (mesosphere KAddressArbiter::SignalAndModifyByWaitingCountIfEqual; yuzu, LAS Recomp and nxnative agree). The word was already updated with compare-and-swap, as LAS Recomp 1144aea made theirs; the count > 0 values were right. --probe-kernel arbiter part expected the old value: it now wants 4 for value 6 with one waiter and count -1, and 4 for two waiters and count 0. Before: 5 for both. After: ok. Co-Authored-By: Claude Opus 5.5 --- runtime/kernel.c | 4 +++- runtime/kernel_probe.c | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/runtime/kernel.c b/runtime/kernel.c index 9d3e2700..2e86a005 100644 --- a/runtime/kernel.c +++ b/runtime/kernel.c @@ -1888,7 +1888,9 @@ int kern_svc(Cpu *c, uint32_t imm) { if (type != 0) { int32_t nv = val + 1; if (type == 2) { - if (cnt <= 0) nv = nw > 0 ? val - 1 : val + 1; + // Waking all (count <= 0) with waiters subtracts 2, not 1 (mesosphere + // KAddressArbiter::SignalAndModifyByWaitingCountIfEqual; yuzu, LAS Recomp and nxnative agree). + if (cnt <= 0) nv = nw > 0 ? val - 2 : val + 1; else nv = nw == 0 ? val + 1 : nw <= cnt ? val - 1 : val; } uint32_t e = (uint32_t)val; diff --git a/runtime/kernel_probe.c b/runtime/kernel_probe.c index 2b54d02a..191a1c28 100644 --- a/runtime/kernel_probe.c +++ b/runtime/kernel_probe.c @@ -305,9 +305,18 @@ static void test_arbiter(Cpu *c) { CHECK(sig(c, 2, 5, 0) == 0 && g_arb == 6, "signal type 2, no waiter, count 0: value %d (want 6)", (int32_t)g_arb); ArbJob jc = {.type = 2, .value = 6, .timeout = -1}; h = arb_spawn(c, &jc); - CHECK(sig(c, 2, 6, -1) == 0 && g_arb == 5, "signal type 2, 1 waiter, count -1: value %d (want 5)", (int32_t)g_arb); + // Waking all (count <= 0) with waiters subtracts 2 (mesosphere KAddressArbiter::SignalAndModifyByWaitingCountIfEqual). + CHECK(sig(c, 2, 6, -1) == 0 && g_arb == 4, "signal type 2, 1 waiter, count -1: value %d (want 4)", (int32_t)g_arb); join(c, h); - CHECK(ja.r == 0 && jb.r == 0 && jc.r == 0, "wait type 2 results %#x %#x %#x", ja.r, jb.r, jc.r); + g_arb = 6; + ArbJob jd = {.type = 2, .value = 6, .timeout = -1}, je = jd; + uint32_t hd = arb_spawn(c, &jd), he = arb_spawn(c, &je); + CHECK(sig(c, 2, 6, 0) == 0 && g_arb == 4, "signal type 2, 2 waiters, count 0: value %d (want 4)", (int32_t)g_arb); + join(c, hd); + join(c, he); + CHECK(ja.r == 0 && jb.r == 0 && jc.r == 0 && jd.r == 0 && je.r == 0, "wait type 2 results %#x %#x %#x %#x %#x", ja.r, + jb.r, jc.r, jd.r, je.r); + g_arb = 5; CHECK(sig(c, 2, 7, 1) == KERN_R_BAD_STATE, "signal type 2 with a different value"); // timeout, invalid type uint64_t t0 = kern_now_ns(); From 53d3f8ea2ea0bc477f6a4b392defe7159e5ea3b0 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 09:20:32 +0300 Subject: [PATCH 21/39] The 1.2.1 texture loader widens a texture's pool offset unsigned The loader keeps a texture's place in its pool as a 32-bit word (main+0xad4294) and widens it with a sign extend for nvnTextureBuilderSetStorage's offset (main+0xad43f0) and for SetPackagedTextureData's source (main+0xad440c). Past 2 GiB the offset turns negative and the storage lands 4 GiB below the pool (MacNativeGames ca311e2d, seen with a 4K texture pack's heap table in a 3.58 GiB pool). The shipped heap table keeps every pool far below 2 GiB, but TOTK_APP_POOL raised with such a pack's heap table (RomFS mods replace it) can reach it. The new patch set pooloff (TOTK_POOL_OFFSET_PATCH, on by default) widens unsigned: the same value below 2 GiB, right up to 4 GiB. 1.4.3 needs no patch: both of its loaders (main+0x583c34, main+0x27ed214) keep the offset in 64 bits (str x25 / mov x2, x25 / add x1, x25, x8). gen.py accepts both original words, and the lifted C at the sites reads the offset as uint32_t with the set on and as int32_t with it off. Co-Authored-By: Claude Opus 5.5 --- recomp/patches-v121.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/recomp/patches-v121.txt b/recomp/patches-v121.txt index a68eb80a..2a5d1257 100644 --- a/recomp/patches-v121.txt +++ b/recomp/patches-v121.txt @@ -82,6 +82,17 @@ site aspect main 12521cc bd414260 f81f0fea @page:x10 bd403940 f84107ea # ldr s0 site aspect main 19b36f0 1e211800 f81f0fea @page:x10 bd403940 f84107ea # fdiv s0, s0, s1 (viewport) site aspect main 1ecdf68 1e211800 f81f0fea @page:x10 bd403940 f84107ea # fdiv s0, s0, s1 (viewport) +# Texture pool offsets past 2 GiB (MacNativeGames ca311e2d). The texture loader keeps a texture's place in its pool as a +# 32-bit offset (main+0xad4294: sub x25, x9, x10, stored as the word w25) and widens it as a signed number for +# nvnTextureBuilderSetStorage's offset and for SetPackagedTextureData's source (the pool's CPU address + offset). The +# shipped heap table keeps every pool far below 2 GiB; a texture pack's heap table with TOTK_APP_POOL raised can make +# one larger (MacNativeGames saw a 3.58 GiB pool), and every texture past 2 GiB then got storage 4 GiB below its pool. +# The offset is widened unsigned instead, the same value below 2 GiB, right up to 4 GiB. 1.4.3 needs no patch: its +# loaders (main+0x583c34, main+0x27ed214) keep the offset in 64 bits. +set pooloff TOTK_POOL_OFFSET_PATCH 1 +site pooloff main ad43f0 93407f22 2a1903e2 # sxtw x2, w25 -> mov w2, w25 (SetStorage offset) +site pooloff main ad440c 8b39c101 8b394101 # add x1, x8, w25, sxtw -> add x1, x8, w25, uxtw (packaged data source) + # Spin waits, from tools/spin_waits.py --exefs build/extracted/v121/exefs --patches recomp/patches-v121.txt: loops that only # another thread can end; their back-edges pass rt_spin_gate (runtime/kernel.c), TOTK_SPIN=0 keeps the core. spin main ac600 ac620 ac630 # 13 instructions; loads From 710b7f5dd0b37bdd34d908dce1ab671491f53a16 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 09:50:30 +0300 Subject: [PATCH 22/39] The device-lost report names the pass or dispatch the GPU was running The replay marks the start and the end of every render pass and dispatch (vk_exec.c vk_exec_pass_begin and vk_exec_pass_end, vk_exec_draw.c op_dispatch and op_dispatch_ind), outside the pass so no pass is broken. With VK_AMD_buffer_marker the GPU writes the last started and finished mark into words 2 and 3 of the breadcrumb buffer; with VK_NV_device_diagnostic_checkpoints the same points are checkpoints, read with vkGetQueueCheckpointData2NV after the loss. A ring of 4096 records maps a mark to its submission, index, target formats and size, draw count and the input ids of the first and last draw's shaders. vk_device_fault_report logs both marks with their records. The calls go through the recorder thread. TOTK_PASS_CRUMBS (menu: GPU pass marks): unset, AMD buffer marks only; 1, also NVIDIA checkpoints, whose extension is enabled only then; 0, none. The design follows Savage Wind Recomp 3fae40c; the code is ours. --probe-vkexec gets a pass marks part: pass, dispatch, pass in one submission; the marks read back after the fence name the last pass and the report text says so. Co-Authored-By: Claude Opus 5.5 --- docs/tester-guide.md | 4 + runtime/cfg.c | 5 +- runtime/ui/menu.cpp | 1 + runtime/ui_text.c | 8 ++ runtime/ui_text.h | 1 + runtime/vk.h | 40 ++++++++ runtime/vk_crumbs.c | 206 +++++++++++++++++++++++++++++++++++++++- runtime/vk_device.c | 13 ++- runtime/vk_exec.c | 24 +++++ runtime/vk_exec.h | 7 ++ runtime/vk_exec_draw.c | 20 ++++ runtime/vk_exec_probe.c | 79 ++++++++++++++- runtime/vk_rec.c | 10 ++ runtime/vk_rec.h | 2 + totk.ini.example | 4 + 15 files changed, 414 insertions(+), 10 deletions(-) diff --git a/docs/tester-guide.md b/docs/tester-guide.md index cf661ba9..2ddd4ac9 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -39,6 +39,10 @@ build\\totk.exe `TOTK_VK_VALIDATION=1` turns on the Vulkan validation layer (it comes with the Vulkan SDK). It makes the game slower; do one run with it and one without (`set TOTK_VK_VALIDATION=`). +If the game stops with a lost device (the log says `device lost` or `VK_ERROR_DEVICE_LOST`), the lines starting with +`vk: pass marks` name the render pass or dispatch the GPU was running. AMD GPUs write them by default. On an NVIDIA GPU, +`set TOTK_PASS_CRUMBS=1` (or GPU pass marks: On in the Advanced tab) before the run that reproduces the crash. + The game keeps running behind other windows (`assume_focus = 0` in `totk.ini` pauses it instead). Shaders the preparation did not cover compile on first use, so the first minutes of play can stutter. Play into the open field (load a save, walk around for a minute, fight something if you can), then close the window. diff --git a/runtime/cfg.c b/runtime/cfg.c index 43137260..53f2bb98 100644 --- a/runtime/cfg.c +++ b/runtime/cfg.c @@ -44,7 +44,7 @@ static const char *const known[] = { "HDR_PEAK_NITS", "HDR_UI_NITS", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD", "LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MENU_LANG", "MENU_TEST", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MOTION", "MOUSE_SENS", "MOVIE_HOST", "MOVIE_PACE", - "ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PATCH_OUTPUT", "PATCH_SCENE", "PCSS", "PCSS_K", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT", + "ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PASS_CRUMBS", "PATCH_OUTPUT", "PATCH_SCENE", "PCSS", "PCSS_K", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT", "PREWARM_XLATE", "PROBE_MOVIE", "PROBE_MOVIE_SHOT", "PROFILE", "PROFILE_CHAINS", "PROFILE_DIR", "PROFILE_FRAMES", "PROFILE_MAIN", "PROFILE_RENDER", "PROMPTS", "PUSH_SKIP", "PUSH_STAGE", "RDOC", "RDOC_DIR", "RDOC_DLL", "REALTIME", "REC_END", "REEXEC", "RENDER_SCALE", "REPLAY_ASYNC", @@ -95,7 +95,7 @@ static const struct { const char *key, *section; } sections[] = { {"BIND_SKIP", "performance"}, {"SHADER_MEMO", "performance"}, {"PIPE_MEMO", "performance"}, {"TEX_MEMO", "performance"}, {"DESC_TEMPLATE", "performance"}, {"PUSH_SKIP", "performance"}, {"SPLIT_DRAWS", "performance"}, {"SPLIT_US", "performance"}, {"VK_VALIDATION", "performance"}, {"VK_DYN_VERTEX", "performance"}, {"VK_DYN_A2C", "performance"}, {"VK_SUBGROUP_PIN", "performance"}, - {"TRACE", "diagnostics"}, {"FLIGHT", "diagnostics"}, {"FLIGHT_SPIKE_MS", "diagnostics"}, + {"TRACE", "diagnostics"}, {"FLIGHT", "diagnostics"}, {"PASS_CRUMBS", "diagnostics"}, {"FLIGHT_SPIKE_MS", "diagnostics"}, {"WB_FENCE_LIMIT", "diagnostics"}, {"WB_EXACT", "diagnostics"}, }; @@ -167,6 +167,7 @@ static const CfgMeta metas[] = { // diagnostics {"TRACE", "", NULL, RESTART}, {"FLIGHT", "0", "0|1", RESTART}, {"FLIGHT_SPIKE_MS", "100", "0..60000", RESTART}, {"WB_FENCE_LIMIT", "65536", "0..1073741824", RESTART}, {"WB_EXACT", "1", "0|1", RESTART}, + {"PASS_CRUMBS", "", "0|1", RESTART}, // vk_crumbs.c pass marks; unset: VK_AMD_buffer_marker only {"INPUT_RECORD", "", NULL, RESTART}, }; diff --git a/runtime/ui/menu.cpp b/runtime/ui/menu.cpp index ecc75a9f..a4a6e33d 100644 --- a/runtime/ui/menu.cpp +++ b/runtime/ui/menu.cpp @@ -962,6 +962,7 @@ static void tab_advanced(float scale) { row_set_or_not("TOTK_TRACE", UI_MENU_TRACE, UI_MENU_TRACE_DESC, "perf"); opt_toggle("TOTK_FLIGHT", UI_MENU_FLIGHT, UI_MENU_FLIGHT_DESC); opt_slider("TOTK_FLIGHT_SPIKE_MS", UI_MENU_FLIGHT_SPIKE_MS, 20, 1000, UI_MENU_FLIGHT_SPIKE_MS_DESC, 10, "%.0f"); + opt_choice("TOTK_PASS_CRUMBS", UI_MENU_PASS_CRUMBS, dyn, 3, UI_MENU_PASS_CRUMBS_DESC); // vk_crumbs.c pass marks static char rec_path[600]; // the recording goes beside the log if (!rec_path[0]) { char dir[512]; diff --git a/runtime/ui_text.c b/runtime/ui_text.c index 719dd9df..03ca1186 100644 --- a/runtime/ui_text.c +++ b/runtime/ui_text.c @@ -390,6 +390,8 @@ static const char *const text_en[UI_TEXT_COUNT] = { [UI_MENU_FLIGHT_DESC] = "Keeps a short history and saves it when a frame freezes.", [UI_MENU_FLIGHT_SPIKE_MS] = "Freeze threshold (ms)", [UI_MENU_FLIGHT_SPIKE_MS_DESC] = "Frame time that counts as a freeze.", + [UI_MENU_PASS_CRUMBS] = "GPU pass marks", + [UI_MENU_PASS_CRUMBS_DESC] = "After a GPU crash, the log names the render pass that was running. Automatic: AMD only. On: also NVIDIA, at a small cost.", [UI_MENU_INPUT_RECORD] = "Input recording", [UI_MENU_INPUT_RECORD_DESC] = "Writes your inputs to a file for bug reports.", [UI_MENU_OPEN_LOG] = "Open log folder", [UI_MENU_OPEN_LOG_DESC] = "Shows the folder with the log files.", @@ -820,6 +822,8 @@ static const char *const text_ru[UI_TEXT_COUNT] = { [UI_MENU_FLIGHT_DESC] = "Хранит короткую историю и сохраняет её, когда кадр подвисает.", [UI_MENU_FLIGHT_SPIKE_MS] = "Порог подвисания (мс)", [UI_MENU_FLIGHT_SPIKE_MS_DESC] = "Время кадра, которое считается подвисанием.", + [UI_MENU_PASS_CRUMBS] = "Метки проходов GPU", + [UI_MENU_PASS_CRUMBS_DESC] = "После сбоя GPU журнал называет проход отрисовки, который выполнялся. Автоматически: только AMD. Вкл.: также NVIDIA, с небольшой нагрузкой.", [UI_MENU_INPUT_RECORD] = "Запись ввода", [UI_MENU_INPUT_RECORD_DESC] = "Пишет ваш ввод в файл для отчёта об ошибке.", [UI_MENU_OPEN_LOG] = "Открыть папку журналов", [UI_MENU_OPEN_LOG_DESC] = "Показывает папку с файлами журнала.", @@ -1168,6 +1172,8 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = { [UI_MENU_FLIGHT_DESC] = "Guarda um histórico curto e o salva quando um quadro trava.", [UI_MENU_FLIGHT_SPIKE_MS] = "Limite de travamento (ms)", [UI_MENU_FLIGHT_SPIKE_MS_DESC] = "Tempo de quadro que conta como travamento.", + [UI_MENU_PASS_CRUMBS] = "Marcas de passagem da GPU", + [UI_MENU_PASS_CRUMBS_DESC] = "Após uma falha da GPU, o log indica a passagem de renderização que estava em execução. Automático: só AMD. Ligado: também NVIDIA, com um pequeno custo.", [UI_MENU_INPUT_RECORD] = "Gravação de comandos", [UI_MENU_INPUT_RECORD_DESC] = "Grava os seus comandos num arquivo para relatórios de bug.", [UI_MENU_OPEN_LOG] = "Abrir pasta de logs", [UI_MENU_OPEN_LOG_DESC] = "Mostra a pasta com os arquivos de log.", @@ -1750,6 +1756,8 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = { [UI_MENU_FLIGHT_DESC] = "保留一小段历史记录,在画面卡住时保存。", [UI_MENU_FLIGHT_SPIKE_MS] = "卡顿阈值(ms)", [UI_MENU_FLIGHT_SPIKE_MS_DESC] = "算作卡顿的帧时间。", + [UI_MENU_PASS_CRUMBS] = "GPU 渲染通道标记", + [UI_MENU_PASS_CRUMBS_DESC] = "GPU 崩溃后,日志会写出当时正在运行的渲染通道。自动:仅 AMD。开:NVIDIA 也启用,有少量开销。", [UI_MENU_INPUT_RECORD] = "输入录制", [UI_MENU_INPUT_RECORD_DESC] = "把你的输入写入文件,用于反馈问题。", [UI_MENU_OPEN_LOG] = "打开日志文件夹", diff --git a/runtime/ui_text.h b/runtime/ui_text.h index a7fc7fcf..702cdc4e 100644 --- a/runtime/ui_text.h +++ b/runtime/ui_text.h @@ -194,6 +194,7 @@ typedef enum { UI_MENU_TRACE, UI_MENU_TRACE_DESC, UI_MENU_FLIGHT, UI_MENU_FLIGHT_DESC, UI_MENU_FLIGHT_SPIKE_MS, UI_MENU_FLIGHT_SPIKE_MS_DESC, + UI_MENU_PASS_CRUMBS, UI_MENU_PASS_CRUMBS_DESC, UI_MENU_INPUT_RECORD, UI_MENU_INPUT_RECORD_DESC, UI_MENU_OPEN_LOG, UI_MENU_OPEN_LOG_DESC, UI_MENU_COPY_INFO, UI_MENU_COPY_INFO_DESC, UI_MENU_COPY_INFO_DONE, diff --git a/runtime/vk.h b/runtime/vk.h index 2751dad4..8119ca59 100644 --- a/runtime/vk.h +++ b/runtime/vk.h @@ -33,6 +33,7 @@ typedef struct VkCtx { int has_custom_border_color, has_eds, has_eds2, has_eds3, has_vertex_input_dynamic_state, has_conditional_rendering, has_transform_feedback, has_provoking_vertex, has_device_fault, has_memory_budget, has_robustness2, has_buffer_device_address, has_fragment_shader_interlock, has_buffer_marker, + has_nv_checkpoints, // VK_NV_device_diagnostic_checkpoints (TOTK_PASS_CRUMBS=1, vk_crumbs.c) has_astc_ldr, // textureCompressionASTC_LDR (enabled when reported) has_calibrated, // VK_KHR/EXT_calibrated_timestamps (GPU idle split in the perf line) has_hdr_metadata; // VK_EXT_hdr_metadata (vk_hdr.c vk_hdr_metadata) @@ -87,6 +88,45 @@ void vk_crumbs_release(void); void vk_crumb(VkCommandBuffer cb, int kind, uint64_t n); void vk_crumbs_report(void); // logs the two codes and what they name +// ---- pass marks (vk_crumbs.c): the replay (vk_exec.c vk_exec_pass_begin / vk_exec_pass_end, vk_exec_draw.c +// op_dispatch) marks where each render pass and dispatch starts and where it ends, so that a device loss names the +// pass the GPU was in. A mark is a number; a ring of VK_PASS_MARKS records keeps what it was. With +// VK_AMD_buffer_marker the GPU writes the number of the last mark it started (TOP_OF_PIPE, before vkCmdBeginRendering +// or the dispatch) and of the last it finished (BOTTOM_OF_PIPE, after vkCmdEndRendering or the dispatch) into the +// breadcrumb buffer; with VK_NV_device_diagnostic_checkpoints the same two points are checkpoints, read with +// vkGetQueueCheckpointData(2)NV after the loss. TOTK_PASS_CRUMBS: unset, AMD buffer marks only; 1, also NVIDIA +// checkpoints (the extension is enabled at device creation); 0, none. +#define VK_PASS_MARKS 4096 // power of two +enum { VK_MARK_PASS = 1, VK_MARK_DISPATCH }; +typedef struct { + uint32_t id; // the mark this record belongs to (0: never written) + uint8_t kind; // VK_MARK_* + uint8_t ncolor; // colour attachments + uint32_t step; // index of the pass, or of the dispatch, within its submission, from 0 + uint32_t w, h, d; // render area and layers; a dispatch: group counts (0 0 0: indirect) + uint32_t draws; // draws recorded in the pass; UINT32_MAX until its end is recorded + uint64_t fence; // the submission: the timeline value it signals + VkFormat color[8], depth; // attachment formats (VK_FORMAT_UNDEFINED: none) + const GfxShader *first[2], *last[2];// vertex and fragment shader of the first and last draw; a dispatch: first[0] +} VkPassMark; +// Pass marks wanted and possible (TOTK_PASS_CRUMBS and the extensions): set by vk_crumbs_init. +extern int vk_pass_marks_on; +// vk_device.c create_device, before vkCreateDevice: 1 when TOTK_PASS_CRUMBS asks for NVIDIA checkpoints. +int vk_pass_marks_want_nv(void); +// The next mark: records its start into cb (through the recorder thread when it owns cb) and returns its ring record, +// id, kind and fence set, for the caller to fill. NULL when pass marks are off. Replay thread only. +VkPassMark *vk_pass_mark_begin(VkCommandBuffer cb, int kind, uint64_t fence); +void vk_pass_mark_end(VkCommandBuffer cb, const VkPassMark *m); // records the end of mark m into cb +// The recorder thread's side of both (vk_rec.c): the marker and checkpoint commands themselves. +void vk_pass_mark_cmd(VkCommandBuffer cb, uint32_t id, int end); +// AMD only: the last mark the GPU started and the last it finished (0: none); 0 without buffer marks. +int vk_pass_marks_read(uint32_t *started, uint32_t *finished); +const VkPassMark *vk_pass_mark_record(uint32_t id); // the ring record of mark id; NULL once a later mark took its slot +// The report's text for a started and a finished mark, three lines without a trailing newline: which marks (source: +// the extension they came from), then each with its ring record. vk_pass_marks_report logs it; the probe checks it. +void vk_pass_marks_format(const char *source, uint32_t started, uint32_t finished, char *o, size_t size); +void vk_pass_marks_report(void); // vk_device_fault_report: the AMD words and the NVIDIA checkpoints, formatted + // ---- window and present (gfx_present.c). Host threads only. // vk_present_image: blits the crop rectangle {x, y, w, h} of src (NULL, or w/h <= 0: the whole w x h image), scaled // to the swapchain extent, into the acquired swapchain image and presents it. src is in layout, which is diff --git a/runtime/vk_crumbs.c b/runtime/vk_crumbs.c index 74a391a2..3d34e024 100644 --- a/runtime/vk_crumbs.c +++ b/runtime/vk_crumbs.c @@ -4,19 +4,31 @@ // device loss word 0 names the newest point the GPU reached and word 1 the newest point it had finished: the hang lies // between them. A code is the crumb kind in the top 4 bits and its number in the low 28. The markers cost two small // writes per crumb, and crumbs mark submissions, upload batches, ASTC chunks and presents, not draws. +// +// Pass marks (vk.h vk_pass_mark_begin) use words 2 and 3 of the same buffer: the replay marks the start and the end of +// every render pass and dispatch, and a ring of records says what each mark was. The design follows Savage Wind +// Recomp's GPU checkpoints (refs/SavageWindRecomp runtime/nvn/vulkan/exec.c ck_mark and vk_checkpoints_report: a mark +// per pass and dispatch, numbers in a ring of 4096, text only at the device loss); the code is ours. #include #include +#include +#include "cfg.h" #include "host.h" #include "vk.h" static PFN_vkCmdWriteBufferMarkerAMD write_marker; static VkBuffer buf; static VmaAllocation alloc; -static volatile uint32_t *words; +static volatile uint32_t *words; // 0, 1: crumbs; 2, 3: pass marks -void vk_crumbs_init(VkCtx *c) { - if (!c->has_buffer_marker) return; +// Pass marks (below) +int vk_pass_marks_on; +static int pm_amd; // marks into words 2 and 3 +static PFN_vkCmdSetCheckpointNV set_checkpoint; +static void pass_marks_init(VkCtx *c); + +static void buffer_init(VkCtx *c) { write_marker = (PFN_vkCmdWriteBufferMarkerAMD)vkGetDeviceProcAddr(c->device, "vkCmdWriteBufferMarkerAMD"); VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = 16, .usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT}; @@ -30,14 +42,20 @@ void vk_crumbs_init(VkCtx *c) { return; } words = info.pMappedData; - words[0] = words[1] = 0; + words[0] = words[1] = words[2] = words[3] = 0; rt_log("vk: GPU breadcrumbs on (VK_AMD_buffer_marker)"); } +void vk_crumbs_init(VkCtx *c) { + if (c->has_buffer_marker) buffer_init(c); + pass_marks_init(c); +} + void vk_crumbs_release(void) { VkCtx *c = vk_ctx(); if (buf && c) vmaDestroyBuffer(c->vma, buf, alloc); buf = VK_NULL_HANDLE, alloc = NULL, words = NULL, write_marker = NULL; + vk_pass_marks_on = pm_amd = 0, set_checkpoint = NULL; } void vk_crumb(VkCommandBuffer cb, int kind, uint64_t n) { @@ -65,3 +83,183 @@ void vk_crumbs_report(void) { describe(words[1], finished, sizeof finished); rt_log("vk: GPU breadcrumbs: the GPU last reached %s; everything before %s had finished", reached, finished); } + +// ---- pass marks + +// Cost per pass or dispatch: about twenty stores into the ring and two commands, one at the start and one at the end, +// each a marker write or a checkpoint. Per draw nothing, except that the branch of vk_exec_draw.c prepare_draw that +// records changed pipeline state also notes the shaders of a pass's first draw. A full barrier already precedes every +// pass and dispatch (vk_exec.c vk_exec_pass_begin, vk_exec_draw.c op_dispatch), so the end-of-pipe write adds no wait. +static VkPassMark ring[VK_PASS_MARKS]; +static uint32_t seq; // marks made; only the replay thread writes it + +// TOTK_PASS_CRUMBS: unset, buffer marks where VK_AMD_buffer_marker exists; 1, also NVIDIA checkpoints; 0, neither. +int vk_pass_marks_want_nv(void) { + const char *e = cfg_get("TOTK_PASS_CRUMBS"); + return e && *e && strcmp(e, "0") != 0; +} + +static void pass_marks_init(VkCtx *c) { + const char *e = cfg_get("TOTK_PASS_CRUMBS"); + int off = e && !strcmp(e, "0"); + set_checkpoint = c->has_nv_checkpoints + ? (PFN_vkCmdSetCheckpointNV)vkGetDeviceProcAddr(c->device, "vkCmdSetCheckpointNV") : NULL; + pm_amd = !off && write_marker && words; + vk_pass_marks_on = pm_amd || set_checkpoint; + if (vk_pass_marks_on) + rt_log("vk: pass marks on (%s%s%s; TOTK_PASS_CRUMBS=0 turns them off%s)", pm_amd ? "VK_AMD_buffer_marker" : "", + pm_amd && set_checkpoint ? " and " : "", set_checkpoint ? "VK_NV_device_diagnostic_checkpoints" : "", + set_checkpoint ? "" : ", =1 adds NVIDIA checkpoints where the driver has them"); + else + rt_log("vk: pass marks off (%s)", off ? "TOTK_PASS_CRUMBS=0" + : "no VK_AMD_buffer_marker; TOTK_PASS_CRUMBS=1 asks for NVIDIA checkpoints"); +} + +void vk_pass_mark_cmd(VkCommandBuffer cb, uint32_t id, int end) { + if (pm_amd) + write_marker(cb, end ? VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, buf, + end ? 12 : 8, id); + if (set_checkpoint) set_checkpoint(cb, (const void *)(uintptr_t)((uint64_t)id << 1 | (end != 0))); +} + +VkPassMark *vk_pass_mark_begin(VkCommandBuffer cb, int kind, uint64_t fence) { + if (!vk_pass_marks_on || !cb) return NULL; + uint32_t id = ++seq; + if (!id) id = ++seq; // 0 means no mark + VkPassMark *m = &ring[id & (VK_PASS_MARKS - 1)]; + m->id = id, m->kind = (uint8_t)kind, m->fence = fence, m->draws = UINT32_MAX; + rec_pass_mark(cb, id, 0); + return m; +} + +void vk_pass_mark_end(VkCommandBuffer cb, const VkPassMark *m) { + if (m && cb) rec_pass_mark(cb, m->id, 1); +} + +int vk_pass_marks_read(uint32_t *started, uint32_t *finished) { + if (!pm_amd) return 0; + *started = words[2], *finished = words[3]; + return 1; +} + +const VkPassMark *vk_pass_mark_record(uint32_t id) { + const VkPassMark *m = &ring[id & (VK_PASS_MARKS - 1)]; + return id && m->id == id ? m : NULL; +} + +// The first 8 bytes of the shader's input id (gfx_shader.c), the start of its stored input's name /in/.in. +static unsigned long long shader_id(const GfxShader *s) { + if (!s) return 0; + const uint8_t *iid = gfx_shader_input_id(s); + unsigned long long v = 0; + for (int i = 0; i < 8; i++) v = v << 8 | iid[i]; + return v; +} + +static void describe_mark(uint32_t id, char *o, size_t size) { + const VkPassMark *m = vk_pass_mark_record(id); + if (!id) { + snprintf(o, size, "nothing yet"); + return; + } + if (!m) { + snprintf(o, size, "no record (a later mark took its slot)"); + return; + } + if (m->kind == VK_MARK_DISPATCH) { + snprintf(o, size, "dispatch %u of submission %llu: %ux%ux%u groups%s, cs %016llx", m->step, + (unsigned long long)m->fence, m->w, m->h, m->d, m->w ? "" : " (indirect)", shader_id(m->first[0])); + return; + } + size_t n = (size_t)snprintf(o, size, "pass %u of submission %llu: %ux%u, %u layer%s, colour formats", m->step, + (unsigned long long)m->fence, m->w, m->h, m->d, m->d == 1 ? "" : "s"); + for (int i = 0; i < m->ncolor && i < 8 && n < size; i++) + n += (size_t)snprintf(o + n, size - n, "%s%d", i ? "," : " ", (int)m->color[i]); + if (!m->ncolor && n < size) n += (size_t)snprintf(o + n, size - n, " none"); + if (n < size) n += (size_t)snprintf(o + n, size - n, ", depth format %d", (int)m->depth); + if (n >= size) return; + if (m->draws == UINT32_MAX) + snprintf(o + n, size - n, ", still recording; first draw vs %016llx fs %016llx", shader_id(m->first[0]), + shader_id(m->first[1])); + else if (!m->draws) + snprintf(o + n, size - n, ", no draws"); + else + snprintf(o + n, size - n, ", %u draws; first vs %016llx fs %016llx, last vs %016llx fs %016llx", m->draws, + shader_id(m->first[0]), shader_id(m->first[1]), shader_id(m->last[0]), shader_id(m->last[1])); +} + +void vk_pass_marks_format(const char *source, uint32_t started, uint32_t finished, char *o, size_t size) { + char a[640], b[640]; + describe_mark(started, a, sizeof a); + describe_mark(finished, b, sizeof b); + snprintf(o, size, + "vk: pass marks (%s, %u made): the GPU last started mark %u and last finished mark %u\n" + "vk: last started %u: %s\n" + "vk: last finished %u: %s", + source, (unsigned)seq, (unsigned)started, (unsigned)finished, (unsigned)started, a, (unsigned)finished, b); +} + +static void log_lines(char *t) { + for (char *line = t, *nl; line && *line; line = nl ? nl + 1 : NULL) { + nl = strchr(line, '\n'); + if (nl) *nl = 0; + rt_log("%s", line); + } +} + +// vkGetQueueCheckpointData2NV, else vkGetQueueCheckpointDataNV, on the graphics queue (the replay's): one entry per +// stage with the last checkpoint that stage executed. A checkpoint is the mark's id times 2, plus 1 at its end, so the +// top-of-pipe entry names the last start or end the GPU reached, and the bottom-of-pipe entry the last one before which +// everything had finished: at a start, that is the end of the mark before it. +static void nv_report(void) { + VkCtx *c = vk_ctx(); + if (!c || !set_checkpoint) return; + PFN_vkGetQueueCheckpointData2NV get2 = + (PFN_vkGetQueueCheckpointData2NV)vkGetDeviceProcAddr(c->device, "vkGetQueueCheckpointData2NV"); + PFN_vkGetQueueCheckpointDataNV get1 = + (PFN_vkGetQueueCheckpointDataNV)vkGetDeviceProcAddr(c->device, "vkGetQueueCheckpointDataNV"); + enum { MAXN = 32 }; + uint64_t stage[MAXN], marker[MAXN]; + uint32_t n = 0; + if (get2) { + VkCheckpointData2NV d[MAXN]; + get2(c->gfx_queue, &n, NULL); + if (n > MAXN) n = MAXN; + for (uint32_t i = 0; i < n; i++) d[i] = (VkCheckpointData2NV){.sType = VK_STRUCTURE_TYPE_CHECKPOINT_DATA_2_NV}; + get2(c->gfx_queue, &n, d); + for (uint32_t i = 0; i < n; i++) stage[i] = d[i].stage, marker[i] = (uint64_t)(uintptr_t)d[i].pCheckpointMarker; + } else if (get1) { + VkCheckpointDataNV d[MAXN]; + get1(c->gfx_queue, &n, NULL); + if (n > MAXN) n = MAXN; + for (uint32_t i = 0; i < n; i++) d[i] = (VkCheckpointDataNV){.sType = VK_STRUCTURE_TYPE_CHECKPOINT_DATA_NV}; + get1(c->gfx_queue, &n, d); + for (uint32_t i = 0; i < n; i++) stage[i] = d[i].stage, marker[i] = (uint64_t)(uintptr_t)d[i].pCheckpointMarker; + } else { + rt_log("vk: pass marks: no vkGetQueueCheckpointDataNV entry point"); + return; + } + uint32_t started = 0, finished = 0; + for (uint32_t i = 0; i < n; i++) { + uint32_t id = (uint32_t)(marker[i] >> 1); + int end = (int)(marker[i] & 1); + rt_log("vk: checkpoint of stage %#llx: the %s of mark %u", (unsigned long long)stage[i], end ? "end" : "start", id); + if (stage[i] == VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT) started = id; + if (stage[i] == VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT) finished = end ? id : id ? id - 1 : 0; + } + if (!n) rt_log("vk: pass marks: the driver reported no checkpoints"); + char t[2048]; + vk_pass_marks_format("VK_NV_device_diagnostic_checkpoints", started, finished, t, sizeof t); + log_lines(t); +} + +void vk_pass_marks_report(void) { + if (!vk_pass_marks_on) return; + uint32_t started, finished; + char t[2048]; + if (vk_pass_marks_read(&started, &finished)) { + vk_pass_marks_format("VK_AMD_buffer_marker", started, finished, t, sizeof t); + log_lines(t); + } + nv_report(); +} diff --git a/runtime/vk_device.c b/runtime/vk_device.c index a0338f93..a063955e 100644 --- a/runtime/vk_device.c +++ b/runtime/vk_device.c @@ -261,7 +261,7 @@ static int create_device(void) { CHAIN(fbl, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ATTACHMENT_FEEDBACK_LOOP_LAYOUT_FEATURES_EXT); CHAIN(lr, VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DYNAMIC_RENDERING_LOCAL_READ_FEATURES_KHR); } - vk_dlaa_device_exts(ep, ne, ext, &n_ext, 40); // NGX's; the eight slots after it stay for the ones below + vk_dlaa_device_exts(ep, ne, ext, &n_ext, 40); // NGX's; the eight slots after it stay for the seven below if (has_ext(ep, ne, VK_EXT_MEMORY_BUDGET_EXTENSION_NAME)) { g_vk.has_memory_budget = 1; ext[n_ext++] = VK_EXT_MEMORY_BUDGET_EXTENSION_NAME; } // GPU timestamps on the CPU clock (vk_sync.c): the perf line splits GPU idle time into waiting for a submission or not. if (has_ext(ep, ne, VK_KHR_CALIBRATED_TIMESTAMPS_EXTENSION_NAME)) { g_vk.has_calibrated = 2; ext[n_ext++] = VK_KHR_CALIBRATED_TIMESTAMPS_EXTENSION_NAME; } @@ -272,6 +272,12 @@ static int create_device(void) { } // GPU breadcrumbs for a lost device (vk_crumbs.c): AMD's drivers and RADV report it. if (has_ext(ep, ne, VK_AMD_BUFFER_MARKER_EXTENSION_NAME)) { g_vk.has_buffer_marker = 1; ext[n_ext++] = VK_AMD_BUFFER_MARKER_EXTENSION_NAME; } + // Pass marks on NVIDIA (vk_crumbs.c): checkpoints only with TOTK_PASS_CRUMBS=1, since NVIDIA documents a cost for + // the extension. NVIDIA's drivers report it, AMD's do not. + if (vk_pass_marks_want_nv() && has_ext(ep, ne, VK_NV_DEVICE_DIAGNOSTIC_CHECKPOINTS_EXTENSION_NAME)) { + g_vk.has_nv_checkpoints = 1; + ext[n_ext++] = VK_NV_DEVICE_DIAGNOSTIC_CHECKPOINTS_EXTENSION_NAME; + } // Graphics pipeline library: chained last so it can be dropped again when vk_caps_gpl declines it. void **gpl_link = NULL; if (has_ext(ep, ne, VK_KHR_PIPELINE_LIBRARY_EXTENSION_NAME) && has_ext(ep, ne, VK_EXT_GRAPHICS_PIPELINE_LIBRARY_EXTENSION_NAME)) { @@ -430,6 +436,7 @@ void vk_device_fault_report(VkResult r) { if (done) { plat_mutex_unlock(&lock); return; } done = 1; vk_crumbs_report(); + vk_pass_marks_report(); VkCtx *c = vk_ctx(); PFN_vkGetDeviceFaultInfoEXT get = c && c->has_device_fault ? (PFN_vkGetDeviceFaultInfoEXT)vkGetDeviceProcAddr(c->device, "vkGetDeviceFaultInfoEXT") @@ -500,11 +507,11 @@ int probe_vkdevice(void) { c->device_index, c->props.deviceName, VK_API_VERSION_MAJOR(v), VK_API_VERSION_MINOR(v), VK_API_VERSION_PATCH(v), c->gfx_family, c->xfer_family, c->xfer_shared ? " (shared queue)" : "", c->min_imported_host_alignment); printf("probe-vkdevice: optional: custom_border=%d eds=%d eds2=%d eds3=%d vertex_input=%d cond_render=%d xfb=%d provoking=%d " - "robustness2=%d fault=%d budget=%d bda=%d interlock=%d validation=%d\n", + "robustness2=%d fault=%d budget=%d bda=%d interlock=%d buffer_marker=%d nv_checkpoints=%d validation=%d\n", c->has_custom_border_color, c->has_eds, c->has_eds2, c->has_eds3, c->has_vertex_input_dynamic_state, c->has_conditional_rendering, c->has_transform_feedback, c->has_provoking_vertex, c->has_robustness2, c->has_device_fault, c->has_memory_budget, c->has_buffer_device_address, c->has_fragment_shader_interlock, - c->validation); + c->has_buffer_marker, c->has_nv_checkpoints, c->validation); vk_shutdown(); printf("probe-vkdevice: %s (validation errors %ld)\n", g_errors_total ? "FAILED" : "passed", g_errors_total); return g_errors_total ? 1 : 0; diff --git a/runtime/vk_exec.c b/runtime/vk_exec.c index cf00d6b8..2709471d 100644 --- a/runtime/vk_exec.c +++ b/runtime/vk_exec.c @@ -612,6 +612,20 @@ void vk_exec_pass_begin(VkExec *ex) { if (d->aspect & VK_IMAGE_ASPECT_DEPTH_BIT) da = a; if (d->aspect & VK_IMAGE_ASPECT_STENCIL_BIT) sa = a; } + // The start mark goes between the barrier and vkCmdBeginRendering, its end right after vkCmdEndRendering + // (vk_exec_pass_end): outside the pass, and without breaking it. + VkPassMark *m = ex->mark = vk_pass_mark_begin(ex->cb, VK_MARK_PASS, ex->fence); + if (m) { + m->step = ex->mark_passes++; + m->w = ex->pass_w, m->h = ex->pass_h, m->d = ex->pass_layers; + m->ncolor = (uint8_t)(ex->pass_ncolor < 8 ? ex->pass_ncolor : 8); + for (int i = 0; i < m->ncolor; i++) m->color[i] = ex->pass_color[i]; + m->depth = ex->pass_depth ? ex->pass_depth : ex->pass_stencil; + // The shaders bound now are the first draw's when it repeats the pipeline state; when it does not, + // vk_exec_draw.c prepare_draw replaces them as it records the new state. + vk_exec_draw_shaders(&m->first[0], &m->first[1]); + ex->mark_draws0 = vk_exec_frame.draws; + } VkRenderingInfo ri = {.sType = VK_STRUCTURE_TYPE_RENDERING_INFO, .renderArea = {{0, 0}, {ex->pass_w, ex->pass_h}}, .layerCount = ex->pass_layers, .colorAttachmentCount = ex->pass_ncolor, .pColorAttachments = ca, @@ -629,6 +643,14 @@ void vk_exec_pass_end(VkExec *ex) { vkCmdEndRendering(ex->cb); ex->recorded++; ex->in_pass = 0; + if (ex->mark) { + // vk_exec_frame_log resets the frame counters at a present (TOTK_TRACE=gfx): then only the draws since it count + uint64_t d = vk_exec_frame.draws, d0 = ex->mark_draws0; + ex->mark->draws = (uint32_t)(d >= d0 ? d - d0 : d); + if (ex->mark->draws) vk_exec_draw_shaders(&ex->mark->last[0], &ex->mark->last[1]); + vk_pass_mark_end(ex->cb, ex->mark); + ex->mark = NULL; + } for (int i = 0; i < ex->pass_ncolor && i < 8; i++) // stand-ins of 3D BC targets go to their textures if (ex->color[i].image && ex->color[i].img && ex->color[i].img->image != ex->color[i].image) bc_copy_back(ex, &ex->color[i]); @@ -901,6 +923,8 @@ int vk_exec_record(VkCommandBuffer cb, const uint64_t *handles, int n, uint64_t ex->fence = fence; ex->recorded = 0; ex->in_pass = 0; + ex->mark = NULL; + ex->mark_passes = ex->mark_dispatches = 0; ex->dirty = VK_EXEC_DIRTY_ALL; ex->rt_resolved = 0; // images may have changed since the last command buffer ex->nviews = 0; diff --git a/runtime/vk_exec.h b/runtime/vk_exec.h index 4dd4a747..b0318a77 100644 --- a/runtime/vk_exec.h +++ b/runtime/vk_exec.h @@ -47,6 +47,11 @@ typedef struct VkExec { int recorded; // Vulkan commands recorded so far; every handler adds the ones it records int in_pass; // inside vkCmdBeginRendering uint32_t dirty; // VK_EXEC_DIRTY_* + // Pass marks (vk.h vk_pass_mark_begin): the open pass's record (NULL: none or marks off), vk_exec_frame.draws when + // it began, and the passes and dispatches this submission marked so far. + VkPassMark *mark; + uint64_t mark_draws0; + uint32_t mark_passes, mark_dispatches; // ---- render targets (OP_SET_RT) and the current pass p_set_rt rt; // last OP_SET_RT payload (a change ends the open pass); rt.n 0, no depth: none @@ -126,6 +131,8 @@ void vk_exec_inline_end(int reopen); // vk_exec_draw.c: forget what the command buffer being recorded has bound (pipeline, sets, dynamic state, vertex // buffers): the replay continues in a new one (vk_sync_split). void vk_exec_draw_new_cb(void); +// vk_exec_draw.c: the vertex and fragment shader of the last draw recorded into this command buffer (NULL: none yet). +void vk_exec_draw_shaders(const GfxShader **vs, const GfxShader **fs); VkBuffer vk_exec_inline_buffer(VkDeviceSize size); // vk_exec.c: the stand-in a 3D BC render target is drawn through goes with its image (vk_exec_image_gone). void vk_exec_bc_gone(VkImage image); diff --git a/runtime/vk_exec_draw.c b/runtime/vk_exec_draw.c index 8740ada4..58f03905 100644 --- a/runtime/vk_exec_draw.c +++ b/runtime/vk_exec_draw.c @@ -198,6 +198,21 @@ static void cb_done(void *arg) { // completion thread void vk_exec_draw_new_cb(void) { memset(&B, 0, sizeof B); } +void vk_exec_draw_shaders(const GfxShader **vs, const GfxShader **fs) { + *vs = B.st_valid ? B.st.vs : NULL; + *fs = B.st_valid ? B.st.fs : NULL; +} + +// Marks a dispatch about to be recorded (vk.h vk_pass_mark_begin); the caller ends it after the dispatch. +static VkPassMark *dispatch_mark(VkExec *ex, const GfxShader *cs, uint32_t x, uint32_t y, uint32_t z) { + VkPassMark *m = vk_pass_mark_begin(ex->cb, VK_MARK_DISPATCH, ex->fence); + if (!m) return NULL; + m->step = ex->mark_dispatches++; + m->w = x, m->h = y, m->d = z, m->draws = 0, m->ncolor = 0; + m->first[0] = cs, m->first[1] = m->last[0] = m->last[1] = NULL; + return m; +} + static CbRes *cb_res(VkExec *ex) { if (cur && cur_fence == ex->fence) return cur; cur = calloc(1, sizeof *cur); @@ -2167,6 +2182,7 @@ static int prepare_draw(VkExec *ex, int prim, int32_t count, int restart) { vk_pipeline_set_dynamic(ex->cb, &s); ex->recorded++; B.st = s, B.st_valid = 1; + if (ex->mark && vk_exec_frame.draws == ex->mark_draws0) ex->mark->first[0] = vs, ex->mark->first[1] = fs; } vk_dlaa_draw(ex); record_fixed(ex); @@ -2318,7 +2334,9 @@ static void op_dispatch(VkExec *ex, const p_dispatch *d) { twin_dispatch = 1; bind_resources(ex, info); twin_dispatch = 0; + VkPassMark *m = dispatch_mark(ex, cs, (uint32_t)d->x, (uint32_t)d->y, (uint32_t)d->z); vkCmdDispatch(ex->cb, (uint32_t)d->x, (uint32_t)d->y, (uint32_t)d->z); + vk_pass_mark_end(ex->cb, m); ex->recorded++; if (vk_dump_armed) vk_dump_dispatch(ex, cs, p, info, d); } @@ -2353,7 +2371,9 @@ static void op_dispatch_ind(VkExec *ex, const p_dispatch_ind *d) { vk_exec_barrier(ex); B.c0_valid[2] = 0; // the range no longer holds B.c0[2] } + VkPassMark *m = dispatch_mark(ex, cs, 0, 0, 0); vkCmdDispatchIndirect(ex->cb, ip.buffer, ip.offset); + vk_pass_mark_end(ex->cb, m); ex->recorded++; vk_mem_count(ip.cls, VK_MUSE_IND, sizeof(VkDispatchIndirectCommand)); VK_MEM_BOUND(ip); diff --git a/runtime/vk_exec_probe.c b/runtime/vk_exec_probe.c index a63fafce..fd6d9970 100644 --- a/runtime/vk_exec_probe.c +++ b/runtime/vk_exec_probe.c @@ -3,7 +3,8 @@ // the completion thread; a guest-memory word a callback writes is visible once fence_wait returns; finish drains every // submission; a present of a cleared vk_texture.c image (layout GENERAL) reads back exactly through the swapchain // capture. Clear part: a hand-built op stream (SET_RT to an RGBA8 and a D32 texture, whole-target clears, -// then scissored clears) through submit reads back exactly with read_texels. +// then scissored clears) through submit reads back exactly with read_texels. Pass marks part (test_marks): the GPU's +// buffer marks and the ring records name the last pass of a submission, and the device-lost report text says so. #include #include #include @@ -386,6 +387,74 @@ static uint8_t *draw_prefix(uint8_t *q, prog_t *prog, const pool_t *pool, tex_t return q; } +// ---- pass marks part (vk_crumbs.c): one submission with a pass of 2 draws into the draw part's target, a dispatch and +// a pass of 3 draws into a second target. Once its fence is done, the buffer marks must name the last pass as both +// started and finished, the ring records must hold what the three were, and vk_pass_marks_format (the text +// vk_device_fault_report logs) must name it. Without VK_AMD_buffer_marker or with TOTK_PASS_CRUMBS=0 it is skipped: +// NVIDIA's checkpoints can only be read once the device is lost. +static char marks_text[2048]; +static int marks_skipped; + +static int test_marks(const GfxBackend *b, pool_t *pool, tex_t *color, prog_t *gfx, prog_t *cs) { + uint32_t s0, f0; + if (!vk_pass_marks_read(&s0, &f0)) { + marks_skipped = 1; + return 0; + } + static tex_t second; + init_tex(&second, pool, 0x40000, NVN_FORMAT_RGBA8); + CHECK(0x40000 + second.L.size <= 0x80000, "pass-marks target exceeds its pool slot"); + static _Alignas(8) uint8_t stream[8192]; + uint8_t *q = draw_prefix(stream, gfx, pool, color); + const p_draw d = {NVN_DRAW_PRIMITIVE_TRIANGLES, 0, 3, 0, 0}; + for (int i = 0; i < 2; i++) q = put(q, OP_DRAW, &d, sizeof d); + const p_bind_program bc = {cs, 1 << 5}; + q = put(q, OP_BIND_PROGRAM, &bc, sizeof bc); + const p_dispatch dp = {2, 1, 1, 0}; + q = put(q, OP_DISPATCH, &dp, sizeof dp); + const p_bind_program bg = {gfx, 3}; + q = put(q, OP_BIND_PROGRAM, &bg, sizeof bg); + p_set_rt rt; + memset(&rt, 0, sizeof rt); + rt.n = 1, rt.color[0] = &second; + q = put(q, OP_SET_RT, &rt, sizeof rt); + for (int i = 0; i < 3; i++) q = put(q, OP_DRAW, &d, sizeof d); + q = put(q, OP_END, NULL, 0); + CHECK(q <= stream + sizeof stream, "pass-marks op stream overflow"); + uint64_t handle = (uint64_t)(uintptr_t)stream; + uint64_t f = b->submit(&handle, 1); + CHECK(b->fence_wait(f, 20000000000ull), "fence_wait of the pass-marks submission timed out"); + uint32_t started = 0, finished = 0; + vk_pass_marks_read(&started, &finished); + CHECK(started && started == finished, "after the fence the GPU last started mark %u and finished mark %u (before " + "the submission: %u and %u)", started, finished, s0, f0); + const VkPassMark *p1 = vk_pass_mark_record(finished), *dm = vk_pass_mark_record(finished - 1), + *p0 = vk_pass_mark_record(finished - 2); + CHECK(p1 && p1->kind == VK_MARK_PASS && p1->fence == f && p1->step == 1, + "the last finished mark %u is not pass 1 of submission %llu", finished, (unsigned long long)f); + CHECK(p1->w == TEX_W && p1->h == TEX_H && p1->d == 1 && p1->ncolor == 1 && + p1->color[0] == VK_FORMAT_R8G8B8A8_UNORM && p1->depth == VK_FORMAT_UNDEFINED, + "pass 1 has %ux%u, %u layers, %u colour targets, format %d, depth %d", p1->w, p1->h, p1->d, p1->ncolor, + (int)p1->color[0], (int)p1->depth); + CHECK(p1->draws == 3 && p1->first[0] && p1->first[1] && p1->first[0] == p1->last[0] && p1->first[1] == p1->last[1], + "pass 1 has %u draws (3 expected) and shaders %p %p ... %p %p", p1->draws, (const void *)p1->first[0], + (const void *)p1->first[1], (const void *)p1->last[0], (const void *)p1->last[1]); + CHECK(dm && dm->kind == VK_MARK_DISPATCH && dm->fence == f && dm->step == 0 && dm->w == 2 && dm->h == 1 && + dm->d == 1 && dm->first[0], + "the mark before pass 1 is not dispatch 0 of 2x1x1 groups of submission %llu", (unsigned long long)f); + CHECK(p0 && p0->kind == VK_MARK_PASS && p0->fence == f && p0->step == 0 && p0->draws == 2 && + p0->first[0] == p1->first[0], + "the mark before the dispatch is not pass 0 of submission %llu with 2 draws", (unsigned long long)f); + vk_pass_marks_format("VK_AMD_buffer_marker", started, finished, marks_text, sizeof marks_text); + char want[128]; + snprintf(want, sizeof want, "last finished %u: pass 1 of submission %llu: %ux%u, 1 layer, colour formats %d, ", + finished, (unsigned long long)f, TEX_W, TEX_H, (int)VK_FORMAT_R8G8B8A8_UNORM); + CHECK(strstr(marks_text, want) && strstr(marks_text, ", 3 draws; first vs "), + "the report text lacks \"%s\" or the draw count:\n%s", want, marks_text); + b->texture_release(&second); + return 0; +} + static int test_draw(const GfxBackend *b, uint8_t *guest, int *n_drawn, int *n_tries, int *n_cs) { char root[PLAT_PATH_MAX * 2], path[PLAT_PATH_MAX * 2 + 64]; CHECK(probe_census_dir(root, sizeof root), "no build/v%d/shaders/jobs.json above the exe (run tools/shader_census.py)", @@ -503,10 +572,12 @@ static int test_draw(const GfxBackend *b, uint8_t *guest, int *n_drawn, int *n_t // Compute: a few census compute shaders, one dispatch each, resources unbound (zero buffers and stand-in images). int dispatched = 0; + prog_t *cs_prog = NULL; for (int t = 0; t < 4 && t < nc; t++) { const DrawEnt *one[1] = {&e[cs[(long long)t * nc / (nc < 4 ? nc : 4)]]}; prog_t *prog = dr_prog(root, one, 1); CHECK(prog, "census code file of %.16s is missing", one[0]->key); + cs_prog = prog; uint8_t *q = stream; q = put(q, OP_BEGIN, NULL, 0); const p_bind_program bp = {prog, 1 << 5}; @@ -530,6 +601,7 @@ static int test_draw(const GfxBackend *b, uint8_t *guest, int *n_drawn, int *n_t dispatched++; ind_dispatches++; } + if (test_marks(b, &pool, &color, hit, cs_prog)) return 1; b->texture_release(&color); b->pool_fini(&pool); free(pairs), free(cs), free(e), free(jobs); @@ -936,6 +1008,11 @@ int probe_vkexec(void) { "between submissions mirrored, CPU bytes beside a fill kept, %d bytes compared)\n", mirror_bytes); else printf("probe-vkexec: mirror part skipped (memory mode import)\n"); + if (marks_skipped) + printf("probe-vkexec: pass marks part skipped (no VK_AMD_buffer_marker, or TOTK_PASS_CRUMBS=0)\n"); + else + printf("probe-vkexec: pass marks part passed (pass, dispatch, pass in one submission: the GPU's last started and " + "finished mark is the last pass, ring records exact); the report reads:\n%s\n", marks_text); printf("probe-vkexec: empty pool part passed (a zero-byte pool at the start of another: no Vulkan objects, lookups " "there find the other pool before and after its Finalize)\n"); return 0; diff --git a/runtime/vk_rec.c b/runtime/vk_rec.c index 969139c2..d48fa0b7 100644 --- a/runtime/vk_rec.c +++ b/runtime/vk_rec.c @@ -40,6 +40,7 @@ enum { R_SetRasterizerDiscardEnable, R_SetScissorWithCount, R_SetStencilCompareMask, R_SetStencilOp, R_SetStencilReference, R_SetStencilTestEnable, R_SetStencilWriteMask, R_SetViewportWithCount, R_WriteTimestamp, R_WriteTimestamp2, R_PushTpl, R_Push, R_VertexInput, R_Bool, R_PolygonMode, R_BlendEnable, R_BlendEquation, R_WriteMask, R_EndCb, + R_PassMark, }; static uint64_t now_ns(void) { return plat_now_ns(); } @@ -531,6 +532,14 @@ void rec_polygon_mode(PFN_vkCmdSetPolygonModeEXT fn, VkCommandBuffer cb, VkPolyg pub(); } +typedef struct { uint32_t id; int end; } A_PassMark; +void rec_pass_mark(VkCommandBuffer cb, uint32_t id, int end) { + if (!OWNED(cb)) { vk_pass_mark_cmd(cb, id, end); return; } + A_PassMark *a = put(R_PassMark, sizeof *a); + *a = (A_PassMark){id, end}; + pub(); +} + typedef struct { void *fn; uint32_t first, n; const void *v; } A_Targets; #define TARGET_CMD(fname, R, PFN, T) \ void fname(PFN fn, VkCommandBuffer cb, uint32_t first, uint32_t n, const T *v) { \ @@ -618,6 +627,7 @@ static void exec_packet(uint32_t op, void *p) { case R_BlendEnable: { A_Targets *a = p; ((PFN_vkCmdSetColorBlendEnableEXT)a->fn)(cb, a->first, a->n, a->v); break; } case R_BlendEquation: { A_Targets *a = p; ((PFN_vkCmdSetColorBlendEquationEXT)a->fn)(cb, a->first, a->n, a->v); break; } case R_WriteMask: { A_Targets *a = p; ((PFN_vkCmdSetColorWriteMaskEXT)a->fn)(cb, a->first, a->n, a->v); break; } + case R_PassMark: { A_PassMark *a = p; vk_pass_mark_cmd(cb, a->id, a->end); break; } case R_EndCb: { A_EndCb *a = p; if (a->kind) vk_crumb(cb, a->kind, a->n); diff --git a/runtime/vk_rec.h b/runtime/vk_rec.h index d4054e1e..53ae1464 100644 --- a/runtime/vk_rec.h +++ b/runtime/vk_rec.h @@ -38,6 +38,8 @@ void rec_blend_equation(PFN_vkCmdSetColorBlendEquationEXT fn, VkCommandBuffer cb const VkColorBlendEquationEXT *v); void rec_write_mask(PFN_vkCmdSetColorWriteMaskEXT fn, VkCommandBuffer cb, uint32_t first, uint32_t n, const VkColorComponentFlags *v); +// A pass mark (vk_crumbs.c vk_pass_mark_cmd: a buffer marker and a checkpoint), in order with the commands around it. +void rec_pass_mark(VkCommandBuffer cb, uint32_t id, int end); #define REC_CMDS(X) \ X(BeginQuery, (VkCommandBuffer cb, VkQueryPool p, uint32_t q, VkQueryControlFlags f), (cb, p, q, f)) \ diff --git a/totk.ini.example b/totk.ini.example index d326e511..e3e7f450 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -313,6 +313,10 @@ # takes longer than flight_spike_ms (0 to 60000). # flight = 0 # flight_spike_ms = 100 +# GPU pass marks: after a GPU crash (device lost) the log names the render pass or dispatch the GPU had started and the +# last one it finished. Unset: on AMD GPUs (VK_AMD_buffer_marker). 1: also on NVIDIA GPUs +# (VK_NV_device_diagnostic_checkpoints), which NVIDIA says costs a little. 0: off. +# pass_crumbs = 1 # Largest render target (bytes, 0 to 1073741824) that goes back to its game memory when the game waits for a GPU fence. # A game that reads a larger target without invalidating it sees stale bytes at the default. With trace = gfx the log # names each dirty target over the limit that stays on the GPU ("fence write-back skipped-for-size") and each one over From 731048188509542b1bca5e85dd3981ee1aa3e178 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 14:05:35 +0300 Subject: [PATCH 23/39] Save XMM registers with SSE moves in kern_ap_stub on builds without AVX The asynchronous preemption stub saved and restored YMM0-15 with vmovdqa and ran vzeroupper in inline asm, which -march=x86-64-v2 does not reach. The v2 runtime library therefore carried VEX code, and a CPU without AVX stopped with an illegal instruction the first time a core owner was interrupted, a few seconds into boot (issue #454, Pentium Gold G6400, totk+0xe7a2b50 = kern_ap_stub+0x20). Without __AVX__ the stub now saves XMM0-15 with movdqa into the same slots; v2 game code keeps nothing in the upper YMM halves. The v3 stub is unchanged. Co-Authored-By: Claude Opus 5.5 --- runtime/kernel.c | 148 +++++++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 68 deletions(-) diff --git a/runtime/kernel.c b/runtime/kernel.c index 2e86a005..e8fb48b4 100644 --- a/runtime/kernel.c +++ b/runtime/kernel.c @@ -415,6 +415,18 @@ static int ap_disabled(const RtThread *t) { return g_ap > 0 && ap_tlr(t)[0]; } // in kern_ap_yield and jumps back to kern_ap_rip(), whose value it first stores in the return slot it reserved at // entry, so a stack walk sees the interrupted function as its caller. No ret: the jump keeps call/return pairs // balanced. The waiter never writes the owner's stack. +// An x86-64-v2 build (no __AVX__) saves XMM0-15 with SSE moves into the same 32-byte slots instead: its game code +// keeps nothing in the upper YMM halves, and the CPUs it is for have no VEX encoding, so a vmovdqa there faults with +// an illegal instruction the first time a core is interrupted. +#ifdef __AVX__ +#define AP_VMOV "vmovdqa" +#define AP_V "%ymm" +#define AP_VZEROUPPER " vzeroupper\n" +#else +#define AP_VMOV "movdqa" +#define AP_V "%xmm" +#define AP_VZEROUPPER "" +#endif #ifdef _WIN32 __asm__(".text\n" ".globl kern_ap_stub\n" @@ -446,48 +458,48 @@ __asm__(".text\n" ".seh_setframe %rbp, 0\n" ".seh_endprologue\n" " andq $-32, %rsp\n" - " subq $576, %rsp\n" // 0: shadow space, 32: MXCSR saved, 36: MXCSR default, 64: YMM0-15 - " vmovdqa %ymm0, 64(%rsp)\n" - " vmovdqa %ymm1, 96(%rsp)\n" - " vmovdqa %ymm2, 128(%rsp)\n" - " vmovdqa %ymm3, 160(%rsp)\n" - " vmovdqa %ymm4, 192(%rsp)\n" - " vmovdqa %ymm5, 224(%rsp)\n" - " vmovdqa %ymm6, 256(%rsp)\n" - " vmovdqa %ymm7, 288(%rsp)\n" - " vmovdqa %ymm8, 320(%rsp)\n" - " vmovdqa %ymm9, 352(%rsp)\n" - " vmovdqa %ymm10, 384(%rsp)\n" - " vmovdqa %ymm11, 416(%rsp)\n" - " vmovdqa %ymm12, 448(%rsp)\n" - " vmovdqa %ymm13, 480(%rsp)\n" - " vmovdqa %ymm14, 512(%rsp)\n" - " vmovdqa %ymm15, 544(%rsp)\n" + " subq $576, %rsp\n" // 0: shadow space, 32: MXCSR saved, 36: MXCSR default, 64: YMM0-15 (XMM0-15 without AVX) + " " AP_VMOV " " AP_V "0, 64(%rsp)\n" + " " AP_VMOV " " AP_V "1, 96(%rsp)\n" + " " AP_VMOV " " AP_V "2, 128(%rsp)\n" + " " AP_VMOV " " AP_V "3, 160(%rsp)\n" + " " AP_VMOV " " AP_V "4, 192(%rsp)\n" + " " AP_VMOV " " AP_V "5, 224(%rsp)\n" + " " AP_VMOV " " AP_V "6, 256(%rsp)\n" + " " AP_VMOV " " AP_V "7, 288(%rsp)\n" + " " AP_VMOV " " AP_V "8, 320(%rsp)\n" + " " AP_VMOV " " AP_V "9, 352(%rsp)\n" + " " AP_VMOV " " AP_V "10, 384(%rsp)\n" + " " AP_VMOV " " AP_V "11, 416(%rsp)\n" + " " AP_VMOV " " AP_V "12, 448(%rsp)\n" + " " AP_VMOV " " AP_V "13, 480(%rsp)\n" + " " AP_VMOV " " AP_V "14, 512(%rsp)\n" + " " AP_VMOV " " AP_V "15, 544(%rsp)\n" " stmxcsr 32(%rsp)\n" " movl $0x1f80, 36(%rsp)\n" " ldmxcsr 36(%rsp)\n" " cld\n" - " vzeroupper\n" + AP_VZEROUPPER " call kern_ap_rip\n" " movq %rax, 72(%rbp)\n" // the return slot: rbp + 8 pushes + pushfq " call kern_ap_yield\n" " ldmxcsr 32(%rsp)\n" - " vmovdqa 64(%rsp), %ymm0\n" - " vmovdqa 96(%rsp), %ymm1\n" - " vmovdqa 128(%rsp), %ymm2\n" - " vmovdqa 160(%rsp), %ymm3\n" - " vmovdqa 192(%rsp), %ymm4\n" - " vmovdqa 224(%rsp), %ymm5\n" - " vmovdqa 256(%rsp), %ymm6\n" - " vmovdqa 288(%rsp), %ymm7\n" - " vmovdqa 320(%rsp), %ymm8\n" - " vmovdqa 352(%rsp), %ymm9\n" - " vmovdqa 384(%rsp), %ymm10\n" - " vmovdqa 416(%rsp), %ymm11\n" - " vmovdqa 448(%rsp), %ymm12\n" - " vmovdqa 480(%rsp), %ymm13\n" - " vmovdqa 512(%rsp), %ymm14\n" - " vmovdqa 544(%rsp), %ymm15\n" + " " AP_VMOV " 64(%rsp), " AP_V "0\n" + " " AP_VMOV " 96(%rsp), " AP_V "1\n" + " " AP_VMOV " 128(%rsp), " AP_V "2\n" + " " AP_VMOV " 160(%rsp), " AP_V "3\n" + " " AP_VMOV " 192(%rsp), " AP_V "4\n" + " " AP_VMOV " 224(%rsp), " AP_V "5\n" + " " AP_VMOV " 256(%rsp), " AP_V "6\n" + " " AP_VMOV " 288(%rsp), " AP_V "7\n" + " " AP_VMOV " 320(%rsp), " AP_V "8\n" + " " AP_VMOV " 352(%rsp), " AP_V "9\n" + " " AP_VMOV " 384(%rsp), " AP_V "10\n" + " " AP_VMOV " 416(%rsp), " AP_V "11\n" + " " AP_VMOV " 448(%rsp), " AP_V "12\n" + " " AP_VMOV " 480(%rsp), " AP_V "13\n" + " " AP_VMOV " 512(%rsp), " AP_V "14\n" + " " AP_VMOV " 544(%rsp), " AP_V "15\n" " movq %rbp, %rsp\n" " popq %rbp\n" " popq %r11\n" @@ -525,48 +537,48 @@ __asm__(".text\n" " pushq %rbp\n" " movq %rsp, %rbp\n" " andq $-32, %rsp\n" - " subq $576, %rsp\n" // 32: MXCSR saved, 36: MXCSR default, 64: YMM0-15 - " vmovdqa %ymm0, 64(%rsp)\n" - " vmovdqa %ymm1, 96(%rsp)\n" - " vmovdqa %ymm2, 128(%rsp)\n" - " vmovdqa %ymm3, 160(%rsp)\n" - " vmovdqa %ymm4, 192(%rsp)\n" - " vmovdqa %ymm5, 224(%rsp)\n" - " vmovdqa %ymm6, 256(%rsp)\n" - " vmovdqa %ymm7, 288(%rsp)\n" - " vmovdqa %ymm8, 320(%rsp)\n" - " vmovdqa %ymm9, 352(%rsp)\n" - " vmovdqa %ymm10, 384(%rsp)\n" - " vmovdqa %ymm11, 416(%rsp)\n" - " vmovdqa %ymm12, 448(%rsp)\n" - " vmovdqa %ymm13, 480(%rsp)\n" - " vmovdqa %ymm14, 512(%rsp)\n" - " vmovdqa %ymm15, 544(%rsp)\n" + " subq $576, %rsp\n" // 32: MXCSR saved, 36: MXCSR default, 64: YMM0-15 (XMM0-15 without AVX) + " " AP_VMOV " " AP_V "0, 64(%rsp)\n" + " " AP_VMOV " " AP_V "1, 96(%rsp)\n" + " " AP_VMOV " " AP_V "2, 128(%rsp)\n" + " " AP_VMOV " " AP_V "3, 160(%rsp)\n" + " " AP_VMOV " " AP_V "4, 192(%rsp)\n" + " " AP_VMOV " " AP_V "5, 224(%rsp)\n" + " " AP_VMOV " " AP_V "6, 256(%rsp)\n" + " " AP_VMOV " " AP_V "7, 288(%rsp)\n" + " " AP_VMOV " " AP_V "8, 320(%rsp)\n" + " " AP_VMOV " " AP_V "9, 352(%rsp)\n" + " " AP_VMOV " " AP_V "10, 384(%rsp)\n" + " " AP_VMOV " " AP_V "11, 416(%rsp)\n" + " " AP_VMOV " " AP_V "12, 448(%rsp)\n" + " " AP_VMOV " " AP_V "13, 480(%rsp)\n" + " " AP_VMOV " " AP_V "14, 512(%rsp)\n" + " " AP_VMOV " " AP_V "15, 544(%rsp)\n" " stmxcsr 32(%rsp)\n" " movl $0x1f80, 36(%rsp)\n" " ldmxcsr 36(%rsp)\n" " cld\n" - " vzeroupper\n" + AP_VZEROUPPER " call kern_ap_rip\n" " movq %rax, 88(%rbp)\n" // the return slot: rbp + 10 pushes + pushfq " call kern_ap_yield\n" " ldmxcsr 32(%rsp)\n" - " vmovdqa 64(%rsp), %ymm0\n" - " vmovdqa 96(%rsp), %ymm1\n" - " vmovdqa 128(%rsp), %ymm2\n" - " vmovdqa 160(%rsp), %ymm3\n" - " vmovdqa 192(%rsp), %ymm4\n" - " vmovdqa 224(%rsp), %ymm5\n" - " vmovdqa 256(%rsp), %ymm6\n" - " vmovdqa 288(%rsp), %ymm7\n" - " vmovdqa 320(%rsp), %ymm8\n" - " vmovdqa 352(%rsp), %ymm9\n" - " vmovdqa 384(%rsp), %ymm10\n" - " vmovdqa 416(%rsp), %ymm11\n" - " vmovdqa 448(%rsp), %ymm12\n" - " vmovdqa 480(%rsp), %ymm13\n" - " vmovdqa 512(%rsp), %ymm14\n" - " vmovdqa 544(%rsp), %ymm15\n" + " " AP_VMOV " 64(%rsp), " AP_V "0\n" + " " AP_VMOV " 96(%rsp), " AP_V "1\n" + " " AP_VMOV " 128(%rsp), " AP_V "2\n" + " " AP_VMOV " 160(%rsp), " AP_V "3\n" + " " AP_VMOV " 192(%rsp), " AP_V "4\n" + " " AP_VMOV " 224(%rsp), " AP_V "5\n" + " " AP_VMOV " 256(%rsp), " AP_V "6\n" + " " AP_VMOV " 288(%rsp), " AP_V "7\n" + " " AP_VMOV " 320(%rsp), " AP_V "8\n" + " " AP_VMOV " 352(%rsp), " AP_V "9\n" + " " AP_VMOV " 384(%rsp), " AP_V "10\n" + " " AP_VMOV " 416(%rsp), " AP_V "11\n" + " " AP_VMOV " 448(%rsp), " AP_V "12\n" + " " AP_VMOV " 480(%rsp), " AP_V "13\n" + " " AP_VMOV " 512(%rsp), " AP_V "14\n" + " " AP_VMOV " 544(%rsp), " AP_V "15\n" " movq %rbp, %rsp\n" " popq %rbp\n" " popq %r11\n" From e603095ffa0d019f29bd363912bae333a826c983 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 14:06:40 +0300 Subject: [PATCH 24/39] Refuse instructions above x86-64-v2 in the runtime objects of a v2 build -march=x86-64-v2 does not reach inline assembly, target attributes or intrinsics, which is how kern_ap_stub's YMM saves got into libtotkrt-v2.a (issue #454). tools/check_isa.py disassembles objects and lists every function with VEX/EVEX, BMI1/2, LZCNT, MOVBE, ADX, SHA, AES, PCLMUL or RDRAND/RDSEED code or YMM/ZMM/mask registers, except functions in its DISPATCHED list (sha256.c blocks_ni, behind __builtin_cpu_supports). build_game.py --cpu v2 runs it over the runtime objects after compiling and before linking or archiving, so make_release.py's v2 library build fails too. Objects unchanged since the last clean scan are skipped; a full scan of the runtime takes about a second. The release ships the script, and the installer's runtime fingerprint includes it. Co-Authored-By: Claude Opus 5.5 --- docs/building.md | 5 +- installer/steps.py | 2 +- tools/build_game.py | 17 +++++ tools/check_isa.py | 154 ++++++++++++++++++++++++++++++++++++++++++ tools/make_release.py | 2 +- 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 tools/check_isa.py diff --git a/docs/building.md b/docs/building.md index 08975f8c..75585d71 100644 --- a/docs/building.md +++ b/docs/building.md @@ -95,7 +95,10 @@ of them. ``` `--cpu v2` targets x86-64-v2 (SSE4.2, no AVX2/FMA) for older CPUs; the default `v3` needs AVX2. The v2 build links - `runtime/fma.c`, a correctly rounded software fma the FP lowering needs, and runs slower. + `runtime/fma.c`, a correctly rounded software fma the FP lowering needs, and runs slower. Inline assembly, + target attributes and intrinsics are not bound by `-march`, so a v2 build runs `tools/check_isa.py` over the runtime + objects before it links: any AVX, BMI or other instruction above x86-64-v2 outside a function listed there as + dispatched by a runtime CPU check stops the build. `gen.py` sizes its emit pool from the CPU count and the free memory; `--jobs N` sets it, and so does the environment variable `TOTK_LIFT_JOBS=N`, which the installer's lift step honours too (it has no `--jobs`). When a worker runs out of memory, the pool restarts with half the workers (`emit: out of memory with N workers, retrying with M`), down to one; only an out-of-memory error with one worker fails the step. diff --git a/installer/steps.py b/installer/steps.py index a99a0660..4c9f2ea5 100644 --- a/installer/steps.py +++ b/installer/steps.py @@ -843,7 +843,7 @@ def runtime_key(ctx): tools. Shader preparation depends on these, not on the code cheats a lift adds.""" layout, cpu = ctx.layout, getattr(ctx, 'cpu', 'v3') clang = subprocess.run([str(layout.clang), '--version'], capture_output=True, text=True).stdout - tools = [layout.tools / n for n in ('build_game.py', 'romfs_index.py', 'check_content.py')] + tools = [layout.tools / n for n in ('build_game.py', 'romfs_index.py', 'check_content.py', 'check_isa.py')] return fingerprint('runtime-1', cpu, layout.runtime_lib_for(cpu), layout.runtime, layout.deps or 'system', layout.sysroot or 'host', *tools, clang, *[d.name for d in layout.dlls]) diff --git a/tools/build_game.py b/tools/build_game.py index caf14e6a..9ecf5013 100644 --- a/tools/build_game.py +++ b/tools/build_game.py @@ -68,6 +68,7 @@ import time import urllib.request from pathlib import Path +import check_isa import romfs_index from build_shaders import stale as stale_shaders from check_content import GENERATED_MARKER @@ -715,6 +716,16 @@ def elf_sections(path): return out, hi - (lo or 0) +def objdump_tool(): + """The disassembler for tools/check_isa.py: llvm-objdump of --toolchain, else MSYS2's or the one on PATH.""" + suffix = '.exe' if WIN else '' + for d in (TOOLCHAIN_BIN, UCRT if WIN else None): + for name in ('llvm-objdump', 'objdump'): + if d and (d / (name + suffix)).is_file(): + return str(d / (name + suffix)) + return check_isa.find_objdump() + + def mib(n): return f'{n / (1 << 20):,.1f} MiB' @@ -886,6 +897,12 @@ def main(): size = sum(o.stat().st_size for o in files) print(f' {mod:8} {len(files):5} objects {mib(size):>14}') verify_objects(out, objs, pairs is not None, main_out, pairs, base, env) + if args.cpu == 'v2': + # Inline assembly, target attributes and intrinsics pass -march=x86-64-v2 by; one vmovdqa of a YMM register + # stops a CPU without AVX (tools/check_isa.py) + fail = check_isa.check_objects(objs['runtime'], objdump_tool(), out / 'obj' / 'runtime' / 'isa-v2.json') + if fail: + sys.exit(f'build_game: {fail}') if args.compile_only: return if args.make_runtime_lib: diff --git a/tools/check_isa.py b/tools/check_isa.py new file mode 100644 index 00000000..e6306fdd --- /dev/null +++ b/tools/check_isa.py @@ -0,0 +1,154 @@ +"""Refuse instructions above x86-64-v2 in objects built for --cpu v2. + +The v2 build is for CPUs without AVX, AVX2, FMA, BMI1/2, F16C, LZCNT and MOVBE (a Pentium Gold G6400, an older Core +i7). The compiler keeps generated code inside that set by -march=x86-64-v2, but inline assembly, target attributes and +intrinsics pass through: kern_ap_stub (runtime/kernel.c) saved YMM registers with vmovdqa in the v2 library of 0.3.1 +and crashed such a CPU with an illegal instruction the first time a core was interrupted. tools/build_game.py --cpu v2 +runs this over every runtime object before it links or archives them. + +A function that holds such an instruction behind a runtime CPU check goes in DISPATCHED with that check named. + + python tools/check_isa.py [--objdump PATH] FILE... (objects, archives or executables) + +Standard library only. +""" + +import argparse +import collections +import concurrent.futures +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Mnemonics outside x86-64-v2 that are not VEX/EVEX encoded, or VEX encoded but without a v prefix (BMI1/2). +# lzcnt and tzcnt decode as bsr and bsf on older CPUs and give other results; tzcnt is left out because the +# compiler emits it on any x86-64 level where bsf would do (a nonzero input, or one it checks first). +NON_V2 = {'andn', 'bextr', 'blsi', 'blsmsk', 'blsr', 'bzhi', 'shlx', 'shrx', 'sarx', 'rorx', 'pdep', 'pext', 'mulx', + 'lzcnt', 'movbe', 'adcx', 'adox', 'sha1rnds4', 'sha1nexte', 'sha1msg1', 'sha1msg2', 'sha256rnds2', + 'sha256msg1', 'sha256msg2', 'aesenc', 'aesenclast', 'aesdec', 'aesdeclast', 'aesimc', 'aeskeygenassist', + 'pclmulqdq', 'rdrand', 'rdseed'} +PREFIXES = {'rep', 'repe', 'repz', 'repne', 'repnz', 'lock', 'notrack', 'data16', 'cs', 'ds', 'es', 'ss', 'fs', 'gs'} +WIDE = re.compile(r'%[yz]mm\d|%k[0-7]\b') + +# Function -> the runtime check that keeps it off CPUs without the instructions. +DISPATCHED = { + 'blocks_ni': 'runtime/sha256.c sha256_blocks: __builtin_cpu_supports("sha") && __builtin_cpu_supports("sse4.1")', +} + + +def find_objdump(hint=None): + """llvm-objdump next to the compiler in use, else on PATH, else GNU objdump; None when there is none.""" + for p in ([Path(hint)] if hint else []): + if p.is_file(): + return str(p) + for name in ('llvm-objdump', 'objdump'): + found = shutil.which(name) + if found: + return found + return None + + +def mnemonic(line): + """The mnemonic of one disassembly line (llvm-objdump or GNU objdump, AT&T syntax), or None.""" + s = line.strip() + m = re.match(r'^[0-9a-f]+:\s*', s) + if m: + s = s[m.end():] + words = s.split() + while words and words[0] in PREFIXES: + words = words[1:] + return words[0] if words else None + + +def is_non_v2(line, mn): + if mn.startswith('v') and mn not in ('verr', 'verw'): + return True # VEX or EVEX: AVX, AVX2, FMA, F16C, AVX-512 + if mn.startswith('k') and WIDE.search(line): + return True # AVX-512 mask registers + return mn in NON_V2 or bool(WIDE.search(line)) + + +def scan_one(path, objdump): + """{(file, function): Counter of mnemonics} of one file.""" + hits = collections.defaultdict(collections.Counter) + r = subprocess.run([objdump, '-d', '--no-show-raw-insn', str(path)], capture_output=True, text=True, + errors='replace') + if r.returncode: + sys.exit(f'check_isa: {objdump} failed on {path}: {r.stderr.strip()[:300]}') + obj = str(path) + func = None + for line in r.stdout.splitlines(): + if 'file format' in line: + obj = line.split(':\tfile format')[0].split(': file format')[0].strip() + continue + m = re.match(r'^[0-9a-f]*\s*<(.+)>:$', line) + if m: + func = m.group(1) + continue + if not line[:1].isspace(): + continue + mn = mnemonic(line) + if mn and func not in DISPATCHED and is_non_v2(line, mn): + hits[(obj, func)][mn] += 1 + return hits + + +def scan(paths, objdump): + """{(file, function): Counter of mnemonics} for every function outside DISPATCHED with a non-v2 instruction.""" + hits = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=min(16, os.cpu_count() or 1)) as pool: + for h in pool.map(lambda p: scan_one(p, objdump), paths): + hits.update(h) + return hits + + +def check_objects(paths, objdump, record): + """Scan the objects changed since the last clean scan (their sizes and mtimes in the JSON file record); return + the failure text, or None. Without an objdump it warns and passes: the check guards the build, it is no part + of it.""" + if not objdump: + print('check_isa: no llvm-objdump or objdump; the v2 instruction check is skipped', file=sys.stderr) + return None + try: + seen = json.loads(record.read_text()) + except (OSError, ValueError): + seen = {} + stamp = {str(p): [p.stat().st_size, p.stat().st_mtime_ns] for p in paths} + todo = [p for p in paths if seen.get(str(p)) != stamp[str(p)]] + hits = scan(todo, objdump) + if hits: + record.unlink(missing_ok=True) + return report(hits) + record.write_text(json.dumps(stamp)) + print(f'check_isa: {len(todo)} of {len(paths)} runtime objects scanned, none above x86-64-v2') + return None + + +def report(hits): + lines = [f' {f} in {o}: ' + ', '.join(f'{k} x{v}' for k, v in c.most_common(6)) + for (o, f), c in sorted(hits.items())] + return ('instructions above x86-64-v2 in a --cpu v2 build (a CPU without AVX/BMI stops on them with an illegal ' + 'instruction); use SSE4.2 code under #ifndef __AVX__, or a runtime CPU check and an entry in ' + 'tools/check_isa.py DISPATCHED:\n' + '\n'.join(lines)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('files', nargs='+', type=Path) + ap.add_argument('--objdump', help='llvm-objdump or objdump to use (default: PATH)') + args = ap.parse_args() + objdump = find_objdump(args.objdump) + if not objdump: + sys.exit('check_isa: no llvm-objdump or objdump found') + hits = scan(args.files, objdump) + if hits: + sys.exit(report(hits)) + print(f'check_isa: {len(args.files)} files, no instructions above x86-64-v2') + + +if __name__ == '__main__': + main() diff --git a/tools/make_release.py b/tools/make_release.py index e50d1be7..814eff63 100644 --- a/tools/make_release.py +++ b/tools/make_release.py @@ -105,7 +105,7 @@ LLVM_SKIP_DIRS = {'aarch64-w64-mingw32', 'armv7-w64-mingw32', 'i686-w64-mingw32' LLVM_SKIP_SUFFIXES = {'.idl', '.tlb'} # tools/ files the installer steps run (and what they import), copied from the repo. -TOOLS_FILES = ['build_game.py', 'build_shaders.py', 'button_icons.py', 'check_content.py', 'extract.py', 'hostmem.py', +TOOLS_FILES = ['build_game.py', 'build_shaders.py', 'button_icons.py', 'check_content.py', 'check_isa.py', 'extract.py', 'hostmem.py', 'make_romfs_patches.py', 'pack_shader_cache.py', 'prepare_shaders.py', 'romfs_index.py', 'shader_census.py', 'shader_list.py', 'ultrawide_layout.py', 'cheats.py', 'exefs_patches.py'] TOOLS_DIRS = ['vendor/macnativegames', 'data'] # data/: expected hashes From aed3e83cc248b22b793a7c97934c72c86c2726bf Mon Sep 17 00:00:00 2001 From: ExpertKT Date: Mon, 2 Nov 2026 19:41:07 +0800 Subject: [PATCH 25/39] Say in totk.ini.example that the frame limit should divide the monitor's refresh rate --- totk.ini.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/totk.ini.example b/totk.ini.example index 227a923f..bb0892f8 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -7,6 +7,12 @@ # Frame rate limit: 30, 40, 45, 60, 90, 120, 144, any whole rate from 30 to 240, or display (the refresh rate of the # window's monitor, at most 240). A higher rate gives 240: above it the game's physics runs out of memory. Above 60 # is experimental and needs realtime = 1. Changes while the game runs take effect at once. +# With mailbox or fifo presentation (vk_present, the default) a frame reaches the screen at a vertical blank, so a limit +# that does not divide the monitor's refresh rate keeps each frame on screen for a different number of refreshes. On a +# 320 Hz monitor 80, 64, 40 and 32 divide it and 90 does not: 90 fps alternates 3 and 4 refreshes per frame, which reads +# as judder while the frame counter still shows 90. Likewise on 240 Hz 60, 48 and 40 divide it and on 144 Hz 72, 48 and +# 36 do, while 60 does not. vk_present = immediate shows a frame as soon as it is presented, without this quantisation, +# at the cost of tearing. # fps_limit = 60 # Older key, read only while fps_limit is unset: fps60 = 0 means a limit of 30. # fps60 = 1 From 6f3d553a2323be26fe93b4880fa9f800ad7780fd Mon Sep 17 00:00:00 2001 From: ExpertKT Date: Mon, 2 Nov 2026 19:43:17 +0800 Subject: [PATCH 26/39] Warn in totk.ini.example that memory = import is much slower on a discrete GPU --- totk.ini.example | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/totk.ini.example b/totk.ini.example index 227a923f..d21e7399 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -57,7 +57,10 @@ # astc = auto # Game memory shared with the GPU: auto (mirror on a discrete GPU, import on an integrated one), import (host memory # imported into Vulkan), mirror (pools used as buffers get a copy in video memory) or verify (mirror plus a check of -# every copy; slow). +# every copy; slow). On a discrete GPU import can be far slower than mirror, not slightly slower: the GPU reads the +# game's pools over PCIe. A 5070 Ti Laptop went from 6 ms to 135-230 ms of GPU time per frame with import (5 fps on the +# title screen, bound: gpu, every line of the perf window waiting on a fence). The log names the mode in use: "mem: +# mode import" or "mem: mode mirror". # memory = auto # A new texture created over a live texture's memory starts with that content instead of empty. # tex_alias = 1 From 90a3b07762b9a83c828f1953e63d29a144d5b2c1 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 14:59:49 +0300 Subject: [PATCH 27/39] Stamp an object the replay defers with the submission it records vk_memory.c defer stamped every deferred image, view and buffer with the last submitted timeline value. An object the replay lets go of while it records a submission can be named by that submission too: vk_texture.c slot_resolve hands a draw a released entry, and entry_of destroys released entries at that address later in the same submission. The completion thread then destroyed the object once the GPU reached the earlier value, before the submission naming it ran, and with the recorder thread behind, before the recorder made the call. The stamp is now the later of the last submitted value and vk_exec_recording_fence, as LAS Recomp and Savage Wind Recomp stamp their deferrals (runtime/nvn/vulkan/textures.c defer). TOTK_DEFER_STAMP=0 brings back the old stamp. Co-Authored-By: Claude Opus 5.5 --- runtime/vk_memory.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/runtime/vk_memory.c b/runtime/vk_memory.c index cbcbc165..d8f57e23 100644 --- a/runtime/vk_memory.c +++ b/runtime/vk_memory.c @@ -20,6 +20,7 @@ #include "nvn_api.h" #include "nvn_pool_index.h" #include "vk.h" +#include "vk_exec.h" #include "platform/platform.h" // Timestamps for the tracking cost, in ns; qpc_ms converts. @@ -1937,8 +1938,19 @@ static void defer(int kind, uint64_t handle, VmaAllocation alloc) { plat_once(&cmd_once, cmd_init, NULL); plat_rmutex_lock(&cmd_lock); if (ndead == cap_dead) grow((void **)&dead, &cap_dead, sizeof *dead); - // The open batch may record into the object: it counts as the newest user. - dead[ndead++] = (Dead){kind, handle, alloc, open_active ? open_b.seq : next_seq, tl_noted}; + // The open batch may record into the object: it counts as the newest user. The GPU must also be past every + // submission that names it. tl_noted covers the submitted ones; an object the replay lets go of while it records + // a submission (vk_exec_recording_fence) may be named by that one too, not yet submitted: vk_texture.c + // slot_resolve hands a draw a released entry, and entry_of destroys released entries at that address later in + // the same submission. Stamped with the last submitted value, the object went as soon as the GPU reached it, + // before the submission that names it ran, and with the recorder thread (vk_rec.c) behind, before the recorder + // made the call that names it. LAS Recomp and Savage Wind Recomp stamp every deferral with the submission being + // recorded (runtime/nvn/vulkan/textures.c defer: vk_pending_seq). TOTK_DEFER_STAMP=0 brings back the old stamp. + static int stamp = -1; + if (stamp < 0) stamp = rt_knob("DEFER_STAMP", "1") != 0; + uint64_t tl = tl_noted, rf = stamp ? vk_exec_recording_fence() : 0; + if (rf > tl) tl = rf; + dead[ndead++] = (Dead){kind, handle, alloc, open_active ? open_b.seq : next_seq, tl}; plat_rmutex_unlock(&cmd_lock); } From 328589abbe5c68db5f2bf793db1a5329804aa4e1 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 15:02:38 +0300 Subject: [PATCH 28/39] Add TOTK_REC_CHECK and TOTK_REC_DELAY_US to find objects the recorder still names TOTK_REC_CHECK=1 searches the packets of the part being recorded for each image, view and buffer vk_memory.c defers or destroys. A deferral stamped below the part's value, or a destruction of an object a packet names, is logged with the packet op, both stamps and the GPU's value, and counted by kind and op in a summary at exit. put() zeroes each packet's room while the check is on: packers reserve room for optional arrays they may leave unwritten, and those words held handles from the ring's previous lap. TOTK_REC_DELAY_US=N makes the recorder wait N us before each batch, as on a slow machine. rec_begin takes the part's value for the check. Both are off by default; off, the recorder pays one test per batch and per packet. Co-Authored-By: Claude Opus 5.5 --- runtime/vk_backend.c | 1 + runtime/vk_memory.c | 4 +++ runtime/vk_rec.c | 78 +++++++++++++++++++++++++++++++++++++++++++- runtime/vk_rec.h | 8 ++++- runtime/vk_sync.c | 4 +-- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/runtime/vk_backend.c b/runtime/vk_backend.c index 238a2bc5..62eeaef2 100644 --- a/runtime/vk_backend.c +++ b/runtime/vk_backend.c @@ -315,6 +315,7 @@ static void replay_log(uint64_t frame, int total) { // vk_backend_shutdown. void gfx_exit_log(void) { static int32_t done; + rec_check_log(); if (gfx_replay_async() && r_started && !plat_xchg32(&done, 1)) replay_log(game_frames, 1); } diff --git a/runtime/vk_memory.c b/runtime/vk_memory.c index d8f57e23..d4242a80 100644 --- a/runtime/vk_memory.c +++ b/runtime/vk_memory.c @@ -1807,6 +1807,8 @@ static void collect_locked(VkCtx *c) { for (int i = 0; i < ndead; i++) { Dead *d = &dead[i]; if (d->seq > done_seq || d->tl > tl) { dead[k++] = *d; continue; } + rec_check_handle(d->kind == DEAD_VIEW ? REC_VIEW : d->kind == DEAD_BUFFER_VMA ? REC_BUFFER : REC_IMAGE, d->handle, 1, + d->tl, tl); switch (d->kind) { case DEAD_IMAGE: vkDestroyImage(c->device, (VkImage)(uintptr_t)d->handle, NULL); break; case DEAD_IMAGE_VMA: vmaDestroyImage(c->vma, (VkImage)(uintptr_t)d->handle, d->alloc); break; @@ -1951,6 +1953,8 @@ static void defer(int kind, uint64_t handle, VmaAllocation alloc) { uint64_t tl = tl_noted, rf = stamp ? vk_exec_recording_fence() : 0; if (rf > tl) tl = rf; dead[ndead++] = (Dead){kind, handle, alloc, open_active ? open_b.seq : next_seq, tl}; + rec_check_handle(kind == DEAD_VIEW ? REC_VIEW : kind == DEAD_BUFFER_VMA ? REC_BUFFER : REC_IMAGE, handle, 0, tl, + vk_sync_fence_done()); plat_rmutex_unlock(&cmd_lock); } diff --git a/runtime/vk_rec.c b/runtime/vk_rec.c index d48fa0b7..28bf7ab8 100644 --- a/runtime/vk_rec.c +++ b/runtime/vk_rec.c @@ -26,6 +26,10 @@ static VkCommandBuffer rec_cb; // the part being recorded (set while the static int rec_live; // producer: rec_begin started the recorder for rec_cb static PlatThread *rec_thread; static int rec_prio; // the submitting thread's priority, which the recorder takes +static int rec_chk; // TOTK_REC_CHECK (rec_check_handle) +static uint64_t rec_delay_ns; // TOTK_REC_DELAY_US (rec_main) +static volatile uint64_t part_h0; // put_h at rec_begin: where the packets of the part being recorded start +static volatile uint64_t part_fence; // the value of the part being recorded (rec_begin) // Producer statistics, logged by rec_stats_log. static struct { uint64_t packets, words, drains, fallbacks, drain_ns, full_waits, parts, parts_on; } RS_; @@ -65,6 +69,9 @@ static void rec_main(void *arg) { continue; } } + // TOTK_REC_DELAY_US (debug): the recorder falls this far behind each batch it finds, as on a slow machine, + // so objects the replay lets go of meet packets it has not made yet (TOTK_REC_CHECK). + if (rec_delay_ns) plat_sleep_ns(rec_delay_ns); for (int k = 0; t != h; k++) { uint64_t w = ring[t % RING_WORDS]; uint32_t op = (uint32_t)(w & 0xffff), n = (uint32_t)(w >> 32); @@ -104,6 +111,9 @@ static void *put(uint32_t op, size_t bytes) { } wait_space(n); ring[pos] = (uint64_t)op | n << 32; + // Packers reserve room for optional arrays they may leave unwritten (BeginRendering's depth and stencil); with + // TOTK_REC_CHECK those words would still hold handles from the ring's previous lap. + if (rec_chk) memset(&ring[pos + 1], 0, (n - 1) * 8); put_h += n; RS_.packets++, RS_.words += n; return &ring[pos + 1]; @@ -125,7 +135,7 @@ void rec_drain(void) { RS_.drains++, RS_.drain_ns += now_ns() - t0; } -void rec_begin(VkCommandBuffer cb) { +void rec_begin(VkCommandBuffer cb, uint64_t fence) { static int mode = -1; if (mode < 0) { // Default off on AMD GPUs. With the recorder those drivers lost the device or hung the GPU at startup on @@ -141,6 +151,9 @@ void rec_begin(VkCommandBuffer cb) { if (mode) { plat_event_init(&wake_ev, 0, 0); rec_prio = plat_thread_priority_get(); + rec_chk = rt_knob("REC_CHECK", "0"); // debug switches, read before the recorder starts + const char *d = cfg_get("TOTK_REC_DELAY_US"); + rec_delay_ns = d && *d && atoi(d) > 0 ? (uint64_t)atoi(d) * 1000 : 0; rec_thread = plat_thread_new(rec_main, NULL, "vk recorder"); if (!rec_thread) { rt_log("vk_rec: no recorder thread: the replay makes its Vulkan calls itself"); @@ -148,6 +161,9 @@ void rec_begin(VkCommandBuffer cb) { } else { rt_log("vk_rec: the replay's Vulkan commands are made by a recorder thread%s", mode == 2 ? " in the B blocks (TOTK_AB)" : ""); + if (rec_chk) rt_log("vk_rec: TOTK_REC_CHECK: deferred and destroyed objects are searched in the ring"); + if (rec_delay_ns) rt_log("vk_rec: TOTK_REC_DELAY_US: the recorder waits %llu us before each batch", + (unsigned long long)(rec_delay_ns / 1000)); } } } @@ -155,6 +171,8 @@ void rec_begin(VkCommandBuffer cb) { if (!RT_KNOB_ON(mode)) return; RS_.parts_on++; rec_cb = cb; // the recorder is idle: rec_end drained it + __atomic_store_n(&part_h0, put_h, __ATOMIC_RELEASE); + __atomic_store_n(&part_fence, fence, __ATOMIC_RELEASE); rec_live = 1; } @@ -203,6 +221,64 @@ void rec_stats_log(void) { memset(&RS_, 0, sizeof RS_); } +// TOTK_REC_CHECK=1 (debug, recorder on): the packets of the part being recorded are searched word by word for an object +// vk_memory.c lets go of. put() zeroes each packet's room, so only words a packer wrote can match. A hit is a hazard +// - at a deferral (destroy 0) when the stamp tl is below the part's value: vk_memory.c collect_locked may destroy the +// object once the GPU reaches tl, before the part ran and, with the recorder behind, before it made the call; +// - at a destruction (destroy 1) always: a packet not made yet hands the driver a dead handle, a made one leaves it +// in a command buffer not yet submitted. +// "Made" means before the tail the recorder published, which trails it by up to 64 packets. Hazards are logged (the +// first 64) and counted by kind, packet and case for rec_check_log; named deferrals that wait long enough are only +// counted. Reads race the producer only in words below head, which stay put until the ring +// wraps (16 MiB later). +static const char *const kind_name[REC_KINDS] = {"image", "view", "buffer"}; +static volatile int32_t chk_seen[2], chk_named, chk_logged, chk_hz[2][2][REC_KINDS][64]; // [destroy][made][kind][op] +void rec_check_handle(int kind, uint64_t handle, int destroy, uint64_t tl, uint64_t gpu) { + if (!rec_chk || !handle || kind < 0 || kind >= REC_KINDS || !__atomic_load_n(&rec_live, __ATOMIC_RELAXED)) return; + uint64_t h = __atomic_load_n(&head, __ATOMIC_ACQUIRE), p = __atomic_load_n(&part_h0, __ATOMIC_ACQUIRE); + uint64_t fence = __atomic_load_n(&part_fence, __ATOMIC_ACQUIRE); + if (p > h || h - p > RING_WORDS / 2) return; // a part this large is not searched + plat_inc32(&chk_seen[destroy != 0]); + while (p < h) { + uint64_t w = ring[p % RING_WORDS]; + uint32_t op = (uint32_t)(w & 0xffff), n = (uint32_t)(w >> 32); + if (!n) return; // overwritten meanwhile + if (op != R_PAD) + for (uint32_t i = 1; i < n; i++) { + if (ring[(p + i) % RING_WORDS] != handle) continue; + int made = p < __atomic_load_n(&tail, __ATOMIC_ACQUIRE); + if (!destroy && tl >= fence) { plat_inc32(&chk_named); return; } + plat_inc32(&chk_hz[destroy][made][kind][op & 63]); + if (plat_inc32(&chk_logged) <= 64) + rt_log("vk_rec: check: HAZARD: %s %#llx %s while packet op %u (byte %u of its arguments) of the " + "part being recorded names it, %s; deferral stamp %llu, part value %llu, GPU at %llu", + kind_name[kind], (unsigned long long)handle, destroy ? "destroyed" : "deferred", op, + 8 * (i - 1), made ? "made" : "not known made", (unsigned long long)tl, + (unsigned long long)fence, (unsigned long long)gpu); + return; + } + p += n; + } +} + +void rec_check_log(void) { + static volatile int32_t said; + if (!rec_chk || plat_xchg32(&said, 1)) return; + int any = 0; + for (int d = 0; d < 2; d++) + for (int m = 0; m < 2; m++) + for (int k = 0; k < REC_KINDS; k++) + for (int op = 0; op < 64; op++) + if (chk_hz[d][m][k][op]) { + any = 1; + rt_log("vk_rec: check summary: %d hazards: %s %s, packet op %d %s", (int)chk_hz[d][m][k][op], + kind_name[k], d ? "destroyed" : "deferred", op, m ? "made" : "not known made"); + } + rt_log("vk_rec: check summary: %s; searched for %d objects deferred and %d destroyed while a part was recorded; %d " + "deferred ones the part named waited for its value", any ? "hazards above" : "no hazards", (int)chk_seen[0], + (int)chk_seen[1], (int)chk_named); +} + #define OWNED(cb) (rec_live && (cb) == rec_cb) // A call the ring takes no packet for: everything queued first, then the call here. #define FALLBACK() (rec_drain(), RS_.fallbacks++) diff --git a/runtime/vk_rec.h b/runtime/vk_rec.h index 53ae1464..c4a1faf8 100644 --- a/runtime/vk_rec.h +++ b/runtime/vk_rec.h @@ -15,13 +15,19 @@ // MIT, Copyright (c) 2026 Peiikai): the driver's work behind the commands depends on nothing the replay decides next. #pragma once -void rec_begin(VkCommandBuffer cb); // the submitting thread, sub_lock held +void rec_begin(VkCommandBuffer cb, uint64_t fence); // the submitting thread, sub_lock held; fence: the part's value void rec_end(void); // waits for the recorder; no-op when rec_begin did not start it // The end of part cb: the breadcrumb crumb (0: none, vk_crumbs.c), then vkEndCommandBuffer, both made by the recorder // when it records cb, and the wait for it (1); otherwise rec_end and the breadcrumb, and the caller ends cb (0). int rec_end_cb(VkCommandBuffer cb, int crumb, uint64_t n); void rec_drain(void); // waits until the recorder made every call queued so far void rec_stats_log(void); // TOTK_TRACE=perf: packets, bytes, drains and waits since the last call +// TOTK_REC_CHECK=1 (debug, recorder on): vk_memory.c reports each object it defers (destroy 0, stamped tl) or destroys +// (destroy 1) with the value the GPU reached (gpu); a packet of the part being recorded that still names it is logged +// as a hazard when the object can go before the part ran. rec_check_log: the counts by kind and packet, at exit. +enum { REC_IMAGE, REC_VIEW, REC_BUFFER, REC_KINDS }; +void rec_check_handle(int kind, uint64_t handle, int destroy, uint64_t tl, uint64_t gpu); +void rec_check_log(void); // Extension commands called through device function pointers. void rec_push_tpl(PFN_vkCmdPushDescriptorSetWithTemplateKHR fn, VkCommandBuffer cb, VkDescriptorUpdateTemplate tpl, diff --git a/runtime/vk_sync.c b/runtime/vk_sync.c index 8a4acdcf..ae9ee33b 100644 --- a/runtime/vk_sync.c +++ b/runtime/vk_sync.c @@ -389,7 +389,7 @@ VkCommandBuffer vk_sync_split(void) { vkBeginCommandBuffer(s->cb, &bi); vk_crumb(s->cb, VK_CRUMB_SUBMIT, cur_fence); ts_begin(s->cb); - rec_begin(s->cb); + rec_begin(s->cb, cur_fence); return s->cb; } @@ -418,7 +418,7 @@ uint64_t vk_sync_submit(const uint64_t *handles, int n) { vkBeginCommandBuffer(s->cb, &bi); vk_crumb(s->cb, VK_CRUMB_SUBMIT, fence); ts_begin(s->cb); - rec_begin(s->cb); + rec_begin(s->cb, fence); int recorded = vk_exec_record(s->cb, handles, n, fence); s = &slots[cur_slot]; // the last part (vk_sync_split) if (recorded) ts_end(s->cb, fence); From 4933725112731f4fffde635ede3ee6908d401f35 Mon Sep 17 00:00:00 2001 From: Takeya Saito Date: Mon, 2 Nov 2026 12:46:20 +0000 Subject: [PATCH 29/39] Keep world markers over their target at game resolutions of another shape than 16:9 The wide and tall UI overlays scale each layout's root pane on the stretched axis. The lock-on arrow, the name label of the item or character in front of Link, enemy life bars and notices, and speech balloons are moved in code to the target's projected position on a pane under that root, so the position was scaled as well: on 32:9 these markers sat at half their distance from the middle of the screen, short of what they point at. Those four layouts keep their root at scale 1 and give the scale to the panes under it that the code does not move and no animation scales, so the markers land on their target and keep their 16:9 proportions. Co-Authored-By: Claude Opus 5.5 --- tools/ultrawide_layout.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tools/ultrawide_layout.py b/tools/ultrawide_layout.py index 6c2fff1a..c67d212c 100644 --- a/tools/ultrawide_layout.py +++ b/tools/ultrawide_layout.py @@ -22,6 +22,19 @@ FIELD = {'x': 44, 'y': 48, 'scale_x': 68, 'scale_y': 72} KEEP_ROOT = {'AppMenuBG_00', 'PauseMenuBG_00', 'ShopMenuParasailBG_00', 'ShortCutBG_00', 'TitleMenuBG_00', 'EventFade_00', 'EventFadeWipe_00', 'CameraShutter_00', 'PaAppPictureBookDetail_00', 'AppMenuOverlay_00'} +# Layouts whose markers the game places over a point in the world (the lock-on arrow, the name of the item or character +# in front of Link, enemy life bars and notices, speech balloons): the code moves a pane under the root to the target's +# projected position in the 1920x1080 grid, which spans the whole canvas. Under a scaled root that position would be +# scaled too, so the marker lands short of its target, toward the middle of the screen (at half the distance on 32:9). +# Their root keeps scale 1 and the scale goes one level down instead, to the panes listed here: ones the code does not +# move and no animation scales, so the markers keep their 16:9 proportions. +MARKER_ROOT = { + 'AttentionBalloon_00': ('C_Capture_00', 'N_InOut_00'), # N_PosRoot_00 moves; N_InOut_00 animates y and alpha + 'AttentionLockOn_00': ('N_Offset_00',), # N_PosRoot_00 moves; N_Offset_00 animates y + 'EnemyInfo_00': ('Pa_EnemyLife_00', 'Pa_EnemyNotice_00'), # the parts move; N_EnemyLifeArea_* stay screen-sized + 'BalloonMessage_00': ('Pa_BalloonMessage_00',), +} + def edits(k, hud_corner): """(layout, pane, field, value) for the aspect: k = 16/9 over the aspect (the root's scale), w = how far a 1920-wide layout's edge moves out in its own units (960 * (1 / k - 1)). OFF moves a pane out of sight.""" @@ -222,8 +235,8 @@ def apply(blarc, members, width, height, hud_corner=True): assert k < 1, 'only for canvases of another shape than 16:9' root_scale = 'scale_x' if wide else 'scale_y' changed, layouts = set(), {} - unknown = sorted(n for n in KEEP_ROOT if f'blyt/{n}.bflyt' not in members) - if unknown: raise SystemExit(f'KEEP_ROOT names no layout: {unknown}') + unknown = sorted(n for n in KEEP_ROOT | MARKER_ROOT.keys() if f'blyt/{n}.bflyt' not in members) + if unknown: raise SystemExit(f'KEEP_ROOT or MARKER_ROOT names no layout: {unknown}') def pane_offset(layout, pane): member = f'blyt/{layout}.bflyt' if member not in members: raise SystemExit(f'{layout}: no layout {member} in the archive') @@ -232,6 +245,14 @@ def apply(blarc, members, width, height, hud_corner=True): return find_pane(layouts[member], layout, pane) for member in sorted(m for m in members if m.startswith('blyt/') and m.endswith('.bflyt')): if member[5:-6] in KEEP_ROOT: continue + if member[5:-6] in MARKER_ROOT: # the root stays at 1, its listed panes take the scale + layout = member[5:-6] + for pane in ('RootPane',) + MARKER_ROOT[layout]: + at = pane_offset(layout, pane) + FIELD[root_scale] + have = struct.unpack_from(' 1e-3: raise SystemExit(f'{layout} {pane} {root_scale}: {have}, expected 1.0') + if pane != 'RootPane': struct.pack_into(' 1e-3: raise SystemExit(f'{member[5:-6]} RootPane {root_scale}: {have}, expected 1.0') From ea72b01524a825bf3c40e42d57f433cceaf3b519 Mon Sep 17 00:00:00 2001 From: lowlevel-larry Date: Mon, 2 Nov 2026 09:47:35 -0300 Subject: [PATCH 30/39] Pace audio frames apart and raise the Linux prefill default Device-paced frames rendered back to back after each period pull (1024 samples on PipeWire, 4-5 frames), before the game's audio thread could clear the render event or fill the AUX return ring. Frames now stay at least 4/5 of a frame apart, as the DSP renders one frame every 5 ms on hardware. This fixes AUX clicks at frame seams (#426) and the progressive sound loss of #131 (overlapping render notifications 52.7% -> 0.1%). Linux prefill default raised to 60 ms: at 20 ms PipeWire underran thousands of times within minutes in busy scenes. The reverb ring (#421) and advance_voice stepping parts of this change landed in dev separately (d22a19c, b44caca). Fixes #131 Co-Authored-By: Claude Opus 5.5 --- runtime/audio_mixer.c | 15 +++++++++++---- runtime/audren.c | 13 ++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/runtime/audio_mixer.c b/runtime/audio_mixer.c index 4c212e9c..c82c7b79 100644 --- a/runtime/audio_mixer.c +++ b/runtime/audio_mixer.c @@ -8,7 +8,7 @@ // into its destination mix, folds the submixes, runs the mix effects and writes the device sink's stereo mix into a // single-producer single-consumer ring. The SDL audio thread drains that ring without taking any lock: an empty ring // plays silence and never blocks the render thread or the guest. TOTK_AUDIO=none (or TOTK_AUDIO_OFF) mixes without a -// device; TOTK_AUDIO_PREFILL_MS (0..500, default 20) is the queued audio the output waits for after start or underrun. +// device; TOTK_AUDIO_PREFILL_MS (0..500, default 20, 60 on Linux) is the queued audio the output waits for after start or underrun. // The device's clock paces the render thread, as the audio hardware paces the Switch's DSP (audio_mixer_pace_ns): a // frame is rendered when the ring holds less than the output target, max(prefill, two device periods), so the queue and // the delay from the game to the speakers stay there and the host and device clocks cannot drift apart. LASRecomp and @@ -90,8 +90,15 @@ static void report_once(Mixer *m, unsigned bit, const char *msg, int value) { // ---- output: SDL3 audio stream fed from the ring // How much audio the output waits for before it starts, and again after an underrun: TOTK_AUDIO_PREFILL_MS rounded up // to whole 5 ms render blocks. Returns -1 for a value outside 0..500 ms. +// The Linux default is higher: at 20 ms PipeWire's queue (max(prefill, two 1024-sample periods)) underran +// thousands of times within minutes in busy scenes, and 60 ms removed the underruns. +#ifdef __linux__ +#define PREFILL_DEFAULT_MS 60 +#else +#define PREFILL_DEFAULT_MS 20 +#endif static int prefill_blocks_for(const char *e) { - if (!e || !*e) return 4; + if (!e || !*e) return PREFILL_DEFAULT_MS / 5; char *end; double ms = strtod(e, &end); if (end == e || *end || !(ms >= 0) || ms > 500) return -1; @@ -318,8 +325,8 @@ void audio_mixer_open(AudioRenderer *r) { const char *pe = cfg_get("TOTK_AUDIO_PREFILL_MS"); m->prefill = prefill_blocks_for(pe); if (m->prefill < 0) { - rt_log("audio: TOTK_AUDIO_PREFILL_MS=%s is not 0..500 ms: using 20", pe); - m->prefill = 4; + rt_log("audio: TOTK_AUDIO_PREFILL_MS=%s is not 0..500 ms: using %d", pe, PREFILL_DEFAULT_MS); + m->prefill = PREFILL_DEFAULT_MS / 5; } r->mixer = m; atomic_store(&m->playing, r->state == 0); diff --git a/runtime/audren.c b/runtime/audren.c index 2b3ca42e..4d127161 100644 --- a/runtime/audren.c +++ b/runtime/audren.c @@ -352,7 +352,13 @@ static void render_thread(void *p) { plat_thread_priority(PLAT_PRIO_HIGHEST); const uint64_t frame_ns = (uint64_t)r->param.sample_count * 1000000000ull / (uint64_t)r->param.sample_rate; const double frame = (double)r->param.sample_count / r->param.sample_rate; - uint64_t next = kern_now_ns(); + // When the device pulls a period (1024 samples on PipeWire, 4-5 frames) the ring drops below its target + // and the frames would otherwise render back to back. On hardware the DSP renders one frame every 5 ms and + // the game's audio thread works in between; AUX effects need that time to fill the return ring, or mix_aux + // reads stale samples and the frame clicks at its seams. Device-paced frames stay at least 4/5 of a frame + // apart, which still refills a period well before the device drains it. + const uint64_t gap_ns = frame_ns * 4 / 5; + uint64_t next = kern_now_ns(), last = 0; while (plat_cas32(&r->running, 0, 0)) { uint64_t now = kern_now_ns(); int64_t pace = audio_mixer_pace_ns(r, now); @@ -360,6 +366,10 @@ static void render_thread(void *p) { plat_wait_ns((uint64_t)pace); continue; } + if (pace == 0 && last && now - last < gap_ns) { + plat_wait_ns(gap_ns - (now - last)); + continue; + } if (pace == 0) { next = now + frame_ns; // where the timer goes on if the device stops pacing } else { @@ -367,6 +377,7 @@ static void render_thread(void *p) { else if (now - next > 100000000ull) next = now; // fell far behind (debugger, sleep): resynchronise next += frame_ns; } + last = kern_now_ns(); plat_mutex_lock(&r->lock); if (r->state == 0) { voices_prepare(r); From 36752867474aa4850dbf31f8f3d79866901d12e7 Mon Sep 17 00:00:00 2001 From: lowlevel-larry <350257211+lowlevel-larry@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:40:51 -0300 Subject: [PATCH 31/39] Keep a texture's pool flags instead of reading its pool object later tex_mirrored (vk_texture.c) read t->pool->flags whenever the back end created the host entry of a texture, which can happen long after nvnTextureInitialize. A game may finalize a memory pool and free the memory the NVNmemoryPool object lived in while textures from it are still looked up (on a level change, for instance); reading the freed object then faults. The NVN driver keeps no pointer to the pool either. tex_t now records the pool's flags at Initialize, in the padding after layout[8], so NVNtexture's size is unchanged. Co-Authored-By: Claude Opus 5.5 --- runtime/nvn_api_obj.c | 1 + runtime/nvn_obj.h | 3 +++ runtime/vk_exec_probe.c | 2 +- runtime/vk_texture.c | 3 +-- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/runtime/nvn_api_obj.c b/runtime/nvn_api_obj.c index 6d3c3fcf..ffe0648a 100644 --- a/runtime/nvn_api_obj.c +++ b/runtime/nvn_api_obj.c @@ -197,6 +197,7 @@ NVN_API(nvnTextureInitialize) { o->magic = NVN_TEX_MAGIC; o->d = s->d; o->pool = s->pool; + o->pool_flags = s->pool ? (uint16_t)s->pool->flags : 0; o->off = s->off; o->has_layout = s->has_layout; memcpy(o->layout, s->layout, 8); diff --git a/runtime/nvn_obj.h b/runtime/nvn_obj.h index 662ba149..7b81c6ec 100644 --- a/runtime/nvn_obj.h +++ b/runtime/nvn_obj.h @@ -48,6 +48,9 @@ typedef struct { } tex_builder_t; FITS(tex_builder_t, NVNtextureBuilder); typedef struct { uint32_t magic; nvn_texdesc d; uint8_t has_layout, layout[8]; + uint16_t pool_flags; // the pool's flags at Initialize (in the padding): a game may finalize and free + // the pool object while the back end still looks the texture up, and the + // driver keeps no pointer to it either nvn_texlayout L; uint8_t *mem; pool_t *pool; int64_t off; uint64_t serial; // Initialization serial: the back end uses it to find its texture in the host table (the game may copy the object by value, so object pointers cannot be stored) } tex_t; FITS(tex_t, NVNtexture); diff --git a/runtime/vk_exec_probe.c b/runtime/vk_exec_probe.c index 3663e556..f3011c43 100644 --- a/runtime/vk_exec_probe.c +++ b/runtime/vk_exec_probe.c @@ -175,7 +175,7 @@ static void init_tex(tex_t *t, pool_t *pool, int64_t off, int format) { t->d.target = NVN_TEXTURE_TARGET_2D, t->d.format = format, t->d.width = TEX_W, t->d.height = TEX_H; t->d.depth = 1, t->d.levels = 1; nvn_tex_layout(&t->d, &t->L); - t->pool = pool, t->off = off, t->mem = pool->mem + off, t->serial = 100 + (uint64_t)off; + t->pool = pool, t->pool_flags = (uint16_t)pool->flags, t->off = off, t->mem = pool->mem + off, t->serial = 100 + (uint64_t)off; } // Pixel of the RGBA8 target after the stream: the scissored second clear inside the rectangle, the first elsewhere. diff --git a/runtime/vk_texture.c b/runtime/vk_texture.c index 2a57ca68..995c613d 100644 --- a/runtime/vk_texture.c +++ b/runtime/vk_texture.c @@ -166,8 +166,7 @@ static int same_but_swizzle(const TexKey *a, const TexKey *b) { static int tex_mirrored(const tex_t *t) { if (!t->mem || nvn_gpu_only((uintptr_t)t->mem)) return 0; if (t->has_layout) return 1; - const pool_t *p = t->pool; - if (!p || !(p->flags & (NVN_MEMORY_POOL_FLAGS_CPU_UNCACHED | NVN_MEMORY_POOL_FLAGS_CPU_CACHED))) return 0; + if (!(t->pool_flags & (NVN_MEMORY_POOL_FLAGS_CPU_UNCACHED | NVN_MEMORY_POOL_FLAGS_CPU_CACHED))) return 0; if (t->d.flags & (NVN_TEXTURE_FLAGS_DISPLAY | NVN_TEXTURE_FLAGS_COMPRESSIBLE | NVN_TEXTURE_FLAGS_ADAPTIVE_ZCULL | NVN_TEXTURE_FLAGS_DEPTH16_PREFER_FAST_CLEAR | NVN_TEXTURE_FLAGS_ZCULL_SUPPORT_STENCIL)) return 0; From 5bd11f5d5c766670861d5c4feeba3e40ba498d15 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 17:03:55 +0300 Subject: [PATCH 32/39] Keep the 4/5-frame gap between device-paced audio frames to Linux Co-Authored-By: Claude Opus 5.5 --- runtime/audren.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/runtime/audren.c b/runtime/audren.c index 4d127161..5ede6072 100644 --- a/runtime/audren.c +++ b/runtime/audren.c @@ -356,8 +356,14 @@ static void render_thread(void *p) { // and the frames would otherwise render back to back. On hardware the DSP renders one frame every 5 ms and // the game's audio thread works in between; AUX effects need that time to fill the return ring, or mix_aux // reads stale samples and the frame clicks at its seams. Device-paced frames stay at least 4/5 of a frame - // apart, which still refills a period well before the device drains it. + // apart, which still refills a period well before the device drains it. Linux only: on Windows SDL pulls + // 480-sample periods, and a sink dump (TOTK_AUDIO_DUMP) of the bursts there showed no more seam spikes than + // chance, so a wait on this thread buys nothing. +#ifdef __linux__ const uint64_t gap_ns = frame_ns * 4 / 5; +#else + const uint64_t gap_ns = 0; +#endif uint64_t next = kern_now_ns(), last = 0; while (plat_cas32(&r->running, 0, 0)) { uint64_t now = kern_now_ns(); From 55ba4d312d43a00c6dedb970e2cedf4040d566e2 Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 17:03:56 +0300 Subject: [PATCH 33/39] Say in totk.ini.example that variable refresh removes the frame limit's judder Co-Authored-By: Claude Opus 5.5 --- totk.ini.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/totk.ini.example b/totk.ini.example index 6193c9e0..387169b2 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -12,7 +12,8 @@ # 320 Hz monitor 80, 64, 40 and 32 divide it and 90 does not: 90 fps alternates 3 and 4 refreshes per frame, which reads # as judder while the frame counter still shows 90. Likewise on 240 Hz 60, 48 and 40 divide it and on 144 Hz 72, 48 and # 36 do, while 60 does not. vk_present = immediate shows a frame as soon as it is presented, without this quantisation, -# at the cost of tearing. +# at the cost of tearing. A monitor running with variable refresh (FreeSync, G-Sync) follows the frames instead, so +# there any limit inside its range is even. # fps_limit = 60 # Older key, read only while fps_limit is unset: fps60 = 0 means a limit of 30. # fps60 = 1 From 438aa417474b5a4bb270d9ba228ebe4794f90f7a Mon Sep 17 00:00:00 2001 From: itaskaev2 <165683571+itaskaev2@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:10:05 +0100 Subject: [PATCH 34/39] Keep the scene at render_scale when DLSS cannot run A DLSS mode (quality, balanced, performance) shrank the scene to 67, 58 or 50 % of the output and sharpened the material textures' mip bias to match, even when nvngx_dlss.dll was missing and NGX reported DLSS as not available. The game's plain scaling then stretched the smaller scene, and the negative bias made textures shimmer in motion. With dlaa = 1 the scene size now waits on nvngx_dlss.dll next to totk.exe (checked before the device exists, as the size is decided then) and follows render_scale without it. The mip bias applies only when NGX came up with DLSS. dlaa = 0 keeps the documented size with the game's scaling, without the bias. Co-Authored-By: Claude Opus 5.5 --- runtime/resolution.c | 22 ++++++++++++++++++++++ runtime/vk_dlaa.c | 3 +++ runtime/vk_dlaa.h | 2 ++ runtime/vk_texture.c | 6 ++++-- totk.ini.example | 3 ++- 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/runtime/resolution.c b/runtime/resolution.c index d1941300..894ee0b6 100644 --- a/runtime/resolution.c +++ b/runtime/resolution.c @@ -36,6 +36,7 @@ #include "cfg.h" #include "host.h" +#include "platform/platform.h" #define BASE_W 1920 #define BASE_H 1080 @@ -55,6 +56,23 @@ static struct { double dlss_k; // TOTK_DLSS quality/balanced/performance: the scene's share of the output, else 0 } R; +// The scene size is decided before the device exists, so NGX cannot be asked yet. With dlaa on, NGX loads DLSS from +// nvngx_dlss.dll next to totk.exe (vk_dlaa.c vk_dlaa_init): without that file DLSS never runs, and a DLSS mode would +// leave the game's plain scaling at 50..67 % of the output. dlaa = 0 keeps the documented size with the game's scaling. +static int dlss_dll(void) { +#ifdef _WIN32 + if (!cfg_bool("TOTK_DLAA")) return 1; + char exe[1024], path[1024 + 32]; + if (!plat_exe_path(exe, sizeof exe)) return 1; + char *last = plat_last_sep(exe); + if (!last) return 1; + snprintf(path, sizeof path, "%.*s/nvngx_dlss.dll", (int)(last - exe), exe); + return plat_file_exists(path); +#else + return 1; +#endif +} + static void decide(void) { if (R.decided) return; R.decided = 1; @@ -78,6 +96,10 @@ static void decide(void) { {"quality", 2.0 / 3.0}, {"balanced", 0.58}, {"performance", 0.5}}; for (int i = 0; cfg_bool("TOTK_DLAA") && i < 3; i++) if (!strcmp(m, dlss[i].name)) R.dlss_k = dlss[i].k; + if (R.dlss_k && !dlss_dll()) { + rt_log("resolution: DLSS %s needs nvngx_dlss.dll next to totk.exe, which is missing: the scene follows render_scale", m); + R.dlss_k = 0; + } if (R.dlss_k) { k = R.dlss_k; R.scene_set = 1; diff --git a/runtime/vk_dlaa.c b/runtime/vk_dlaa.c index af926221..7acdd130 100644 --- a/runtime/vk_dlaa.c +++ b/runtime/vk_dlaa.c @@ -202,6 +202,8 @@ void vk_dlaa_init(void) { driver ? "; the driver needs an update" : ""); } +int vk_dlaa_available(void) { return vk_dlaa_wanted() && N.available; } + // ---------------------------------------------------------------- where DLAA runs in TotK's frame // Measured (TOTK_DUMP, frame 2100 at Lookout Landing, 2560x1440 scene): the G-buffer passes draw into five targets with // the scene depth (d32f at the scene size), one of them the R11G11B10F light target; lighting and the transparents @@ -680,6 +682,7 @@ void vk_dlaa_device_exts(const VkExtensionProperties *have, uint32_t nhave, cons (void)have, (void)nhave, (void)ext, (void)n, (void)max; } void vk_dlaa_init(void) {} +int vk_dlaa_available(void) { return 0; } void vk_dlaa_pass(VkExec *ex) { (void)ex; } void vk_dlaa_frame(void) {} void vk_dlaa_draw(VkExec *ex) { (void)ex; } diff --git a/runtime/vk_dlaa.h b/runtime/vk_dlaa.h index dc291b10..685a2524 100644 --- a/runtime/vk_dlaa.h +++ b/runtime/vk_dlaa.h @@ -8,6 +8,8 @@ int vk_dlaa_wanted(void); // the setting is on (the device may still lack DLSS void vk_dlaa_instance_exts(const char **ext, uint32_t *n, uint32_t max); void vk_dlaa_device_exts(const VkExtensionProperties *have, uint32_t nhave, const char **ext, uint32_t *n, uint32_t max); void vk_dlaa_init(void); +// 1 when dlaa is on and NGX came up with DLSS (after vk_dlaa_init): a DLSS mode's smaller scene is scaled up by DLSS. +int vk_dlaa_available(void); // vk_exec.c vk_exec_pass_begin, once the pass's targets are resolved and before its barrier: arms the frame on the // scene's light target or, at the first quarter-width pass after it, runs DLAA on that target. Each present ends the // frame (vk_dlaa_frame). diff --git a/runtime/vk_texture.c b/runtime/vk_texture.c index 995c613d..46abe06f 100644 --- a/runtime/vk_texture.c +++ b/runtime/vk_texture.c @@ -26,6 +26,7 @@ #include "nvn_tex.h" #include "tex.h" #include "vk.h" +#include "vk_dlaa.h" #include "vk_exec.h" #include "platform/platform.h" @@ -1680,8 +1681,9 @@ static VkSampler make_sampler(VkCtx *c, const samp_par *d) { if (si.maxLod < si.minLod) si.maxLod = si.minLod; float lim = c->props.limits.maxSamplerLodBias, bias = d->lod_bias; // DLSS upscaling (resolution.c): the scene's material textures, the filtered mipmapped ones that repeat, are - // sampled for the output's resolution; clamped ones (post effects, LUTs, the UI) keep the game's bias. - if (!d->cmp_mode && mf == NVN_MIN_FILTER_LINEAR_MIPMAP_LINEAR && + // sampled for the output's resolution; clamped ones (post effects, LUTs, the UI) keep the game's bias. Without DLSS + // running (dlaa = 0, or NGX without DLSS) nothing accumulates the extra detail, and the sharper mips only shimmer. + if (vk_dlaa_available() && !d->cmp_mode && mf == NVN_MIN_FILTER_LINEAR_MIPMAP_LINEAR && (si.addressModeU == VK_SAMPLER_ADDRESS_MODE_REPEAT || si.addressModeU == VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT || si.addressModeV == VK_SAMPLER_ADDRESS_MODE_REPEAT || si.addressModeV == VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT)) bias += res_dlss_lod_bias(); diff --git a/totk.ini.example b/totk.ini.example index 387169b2..8edc4535 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -196,7 +196,8 @@ # dlaa = 0 # What DLSS works from: dlaa (the 3D scene at full size), quality, balanced or performance (the scene at 67, 58 or # 50 % of the output, scaled up by DLSS; these replace render_scale and sharpen texture filtering to match). With dlaa -# = 0 the game's own scaling is used at that size. Read at start. +# = 0 the game's own scaling is used at that size, without the sharper filtering. With dlaa = 1 but no nvngx_dlss.dll +# next to totk.exe the scene follows render_scale. Read at start. # dlss = dlaa # The game's own FXAA after the post effects; with DLAA on it is not needed. Changes while the game runs take effect at # once. From 92639896251a2a6b31810f81da50e09af4b166c3 Mon Sep 17 00:00:00 2001 From: itaskaev2 <165683571+itaskaev2@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:32 +0100 Subject: [PATCH 35/39] Skip the game's scaling draw over DLSS's picture again With a DLSS mode (a scene smaller than the output) DLSS writes the scaled picture into the scaling pass's target and vk_dlaa_skip_draw skips the game's own scaling draw, known by its fragment shader's SPIR-V hash. Translator patch 0011 (the shadow filter spread) changed that hash, so the draw ran again and covered DLSS's picture with the scaled jittered scene: the whole picture shook by a fraction of a pixel each frame, most visible on lit flat surfaces in shrines. The FXAA switch (fxaa = 0) stopped working for the same reason. Add the hash of the current translator, and log once when DLSS scaled a frame whose scaling draw was not found, so the next translator change that moves the hash shows in the log. Measured on 1.4.3, Ukouh Shrine, output 3200x1800, dlss = quality, mean absolute change between consecutive frames with a still camera: 1.40 before, 0.07 after (0.054 with dlaa = 0). Outdoors on stone: 4.29 before, 1.08 after. Co-Authored-By: Claude Opus 5.5 --- runtime/vk_dlaa.c | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/runtime/vk_dlaa.c b/runtime/vk_dlaa.c index 7acdd130..886d2a23 100644 --- a/runtime/vk_dlaa.c +++ b/runtime/vk_dlaa.c @@ -256,6 +256,8 @@ static struct { uint64_t mv_frames, mv_none; uint64_t why[3]; // history resets: scene size changed, no camera, scene target changed int on; // the dlaa setting, read at each present (the menu switches it live) + int scaler_skipped; // this frame: the game's scaling draw over DLSS's picture was skipped + uint64_t scaler_missed; // frames DLSS scaled whose scaling draw was not found (vk_dlaa_skip_draw) } D = {.mv_ok = -1}; static int dl_active(void) { return N.available && !D.failed && D.on; } @@ -626,7 +628,10 @@ void vk_dlaa_frame(void) { if (D.jit_used && !D.done) D.hold = 60, D.holds++; // jittered but not resolved: that frame shook else if (D.hold) D.hold--; if (!D.done) D.jit_ok = 0; - D.armed = D.done = D.jit_used = D.gbuf = D.ended = D.up = 0; + if (D.up && !D.scaler_skipped && ++D.scaler_missed == 1) + rt_log("dlaa: the game's scaling draw over DLSS's picture was not found (its fragment shader is not in FXAA_FS): " + "the jittered scene reaches the screen and shakes"); + D.armed = D.done = D.jit_used = D.gbuf = D.ended = D.up = D.scaler_skipped = 0; D.cam_addr = 0; } @@ -640,8 +645,10 @@ void vk_dlaa_frame(void) { // scaled picture into the output-sized target, in a pass without the depth attachment the in-place FXAA has: there it // is always drawn, whatever fxaa says (skipping it would leave the 3D picture out), unless DLSS already wrote that // target this frame. -// The stock translator's hash, and the one with tools/xlate patch 0010 (PCSS, PR #89) applied. -static const uint64_t FXAA_FS[] = {0xf450003ebeb3dc52ull, 0x0a12f4bc2a9a4fbeull}; +// The stock translator's hash, the one with tools/xlate patch 0010 (PCSS, PR #89) applied, and the one with patches +// 0010 and 0011 (the shadow filter spread) applied. A translator change that gives the shader another hash is reported +// by vk_dlaa_frame: the game's scaling draw then covers DLSS's picture with the jittered scene, which shakes. +static const uint64_t FXAA_FS[] = {0xf450003ebeb3dc52ull, 0x0a12f4bc2a9a4fbeull, 0x5c43ae18948d52cbull}; static uint64_t spirv_hash(const GfxShader *s) { static struct { const GfxShader *s; uint64_t h; } tab[8192]; @@ -667,8 +674,10 @@ int vk_dlaa_skip_draw(VkExec *ex, const GfxShader *fs) { if (fxaa && !D.up) return 0; // nothing to skip: no shader lookup per draw at the defaults if (!fs) return 0; uint64_t h = spirv_hash(fs); - if (h != FXAA_FS[0] && h != FXAA_FS[1]) return 0; - if (!ex->depth.image) return D.up; // the scaling pass + int known = 0; + for (size_t i = 0; i < sizeof FXAA_FS / sizeof FXAA_FS[0]; i++) known |= h == FXAA_FS[i]; + if (!known) return 0; + if (!ex->depth.image) return D.scaler_skipped = D.up; // the scaling pass return !fxaa; } From 3ee347b2489f6b80abbd4648a54c688437c4ba0b Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 17:11:14 +0300 Subject: [PATCH 36/39] Size the scene by a DLSS mode only where DLSS can run, and log whether fxaa = 0 found the game's FXAA The DLSS modes already apply only with dlaa on (3de0839), so the DLL check no longer looks at dlaa, and off Windows, where vk_dlaa.c is no-op stubs, a DLSS mode no longer shrinks the scene. fxaa = 0 names the FXAA shader it skipped, or reports a minute of frames without it, so a translator change that moves FXAA_FS shows on any GPU. Co-Authored-By: Claude Opus 5.5 --- runtime/resolution.c | 13 ++++++++----- runtime/vk_dlaa.c | 17 ++++++++++++++++- totk.ini.example | 6 +++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/runtime/resolution.c b/runtime/resolution.c index 894ee0b6..cd93a74c 100644 --- a/runtime/resolution.c +++ b/runtime/resolution.c @@ -56,12 +56,11 @@ static struct { double dlss_k; // TOTK_DLSS quality/balanced/performance: the scene's share of the output, else 0 } R; -// The scene size is decided before the device exists, so NGX cannot be asked yet. With dlaa on, NGX loads DLSS from -// nvngx_dlss.dll next to totk.exe (vk_dlaa.c vk_dlaa_init): without that file DLSS never runs, and a DLSS mode would -// leave the game's plain scaling at 50..67 % of the output. dlaa = 0 keeps the documented size with the game's scaling. +// The scene size is decided before the device exists, so NGX cannot be asked yet. NGX loads DLSS from nvngx_dlss.dll +// next to totk.exe (vk_dlaa.c vk_dlaa_init): without that file DLSS never runs, and a DLSS mode would leave the game's +// plain scaling at 50..67 % of the output. Off Windows vk_dlaa.c is built as no-op stubs, so there is no DLSS at all. static int dlss_dll(void) { #ifdef _WIN32 - if (!cfg_bool("TOTK_DLAA")) return 1; char exe[1024], path[1024 + 32]; if (!plat_exe_path(exe, sizeof exe)) return 1; char *last = plat_last_sep(exe); @@ -69,7 +68,7 @@ static int dlss_dll(void) { snprintf(path, sizeof path, "%.*s/nvngx_dlss.dll", (int)(last - exe), exe); return plat_file_exists(path); #else - return 1; + return 0; #endif } @@ -97,7 +96,11 @@ static void decide(void) { for (int i = 0; cfg_bool("TOTK_DLAA") && i < 3; i++) if (!strcmp(m, dlss[i].name)) R.dlss_k = dlss[i].k; if (R.dlss_k && !dlss_dll()) { +#ifdef _WIN32 rt_log("resolution: DLSS %s needs nvngx_dlss.dll next to totk.exe, which is missing: the scene follows render_scale", m); +#else + rt_log("resolution: DLSS %s runs on Windows only: the scene follows render_scale", m); +#endif R.dlss_k = 0; } if (R.dlss_k) { diff --git a/runtime/vk_dlaa.c b/runtime/vk_dlaa.c index 886d2a23..4872dd32 100644 --- a/runtime/vk_dlaa.c +++ b/runtime/vk_dlaa.c @@ -669,8 +669,19 @@ static uint64_t spirv_hash(const GfxShader *s) { int vk_dlaa_skip_draw(VkExec *ex, const GfxShader *fs) { static uint64_t frame_read = ~0ull; static int fxaa = 1; + // fxaa = 0 reports once whether it found the game's FXAA draw: the first skip, or a minute of frames (3600) with + // none, which is what a translator change that moves FXAA_FS looks like on every GPU, with or without DLSS + static uint64_t fx_off_frames; + static int fx_found, fx_reported; uint64_t f = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED); - if (f != frame_read) frame_read = f, fxaa = cfg_bool("TOTK_FXAA"); // once a frame: the menu switches it live + if (f != frame_read) { // once a frame: the menu switches it live + frame_read = f, fxaa = cfg_bool("TOTK_FXAA"); + if (!fxaa && !fx_found && !fx_reported && ++fx_off_frames == 3600) { + fx_reported = 1; + rt_log("dlaa: fxaa = 0 found no draw of the game's FXAA in %llu frames (its fragment shader is not in " + "FXAA_FS): the FXAA stays on", (unsigned long long)fx_off_frames); + } + } if (fxaa && !D.up) return 0; // nothing to skip: no shader lookup per draw at the defaults if (!fs) return 0; uint64_t h = spirv_hash(fs); @@ -678,6 +689,10 @@ int vk_dlaa_skip_draw(VkExec *ex, const GfxShader *fs) { for (size_t i = 0; i < sizeof FXAA_FS / sizeof FXAA_FS[0]; i++) known |= h == FXAA_FS[i]; if (!known) return 0; if (!ex->depth.image) return D.scaler_skipped = D.up; // the scaling pass + if (!fxaa && !fx_found) { + fx_found = 1; + rt_log("dlaa: fxaa = 0 skips the game's FXAA (fragment shader %016llx)", (unsigned long long)h); + } return !fxaa; } diff --git a/totk.ini.example b/totk.ini.example index 8edc4535..e22dbbee 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -195,9 +195,9 @@ # totk.exe; elsewhere the setting does nothing. The HUD is drawn after it and is not touched. Read at start. # dlaa = 0 # What DLSS works from: dlaa (the 3D scene at full size), quality, balanced or performance (the scene at 67, 58 or -# 50 % of the output, scaled up by DLSS; these replace render_scale and sharpen texture filtering to match). With dlaa -# = 0 the game's own scaling is used at that size, without the sharper filtering. With dlaa = 1 but no nvngx_dlss.dll -# next to totk.exe the scene follows render_scale. Read at start. +# 50 % of the output, scaled up by DLSS; these replace render_scale and sharpen texture filtering to match). They apply +# only with dlaa = 1 and nvngx_dlss.dll next to totk.exe, on Windows; otherwise the scene follows render_scale. Read at +# start. # dlss = dlaa # The game's own FXAA after the post effects; with DLAA on it is not needed. Changes while the game runs take effect at # once. From 4e90c61c522030e9f47c2d502683388d51a7648d Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 17:36:09 +0300 Subject: [PATCH 37/39] Choose the amiibo folder with the host's folder dialog The Amiibo tab's folder field took no typing: the menu never passes typed characters to Dear ImGui, so "Open" only re-read "./amiibo". A missing folder said so at the bottom of the right pane, below its scroll. "Open" now shows the shell's folder dialog on Windows (IFileOpenDialog on a thread of its own, owned by the game window) and SDL_ShowOpenFolderDialog on the SDL window thread elsewhere; the tab shows the folder as text and names a missing one inside the empty list. Co-Authored-By: Claude Opus 5.5 --- runtime/folder_pick.h | 18 ++++++++ runtime/folder_pick_sdl.c | 42 +++++++++++++++++++ runtime/folder_pick_win.cpp | 84 +++++++++++++++++++++++++++++++++++++ runtime/gfx_window_sdl.c | 2 + runtime/input_sdl.h | 1 + runtime/ui/amiibo_page.cpp | 49 +++++++++++++++++----- tools/build_game.py | 4 +- 7 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 runtime/folder_pick.h create mode 100644 runtime/folder_pick_sdl.c create mode 100644 runtime/folder_pick_win.cpp diff --git a/runtime/folder_pick.h b/runtime/folder_pick.h new file mode 100644 index 00000000..d701ef8d --- /dev/null +++ b/runtime/folder_pick.h @@ -0,0 +1,18 @@ +// The host's own folder dialog, for menu pages that point at a folder (the Amiibo tab): folder_pick_win.cpp with Win32, +// folder_pick_sdl.c with SDL. The menu has no text entry, so a folder is chosen here or by walking the page's list. +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// done gets the chosen folder (UTF-8) or NULL when the player cancelled or the host has no dialog. +typedef void (*FolderPickDone)(const char *path, void *ctx); + +// Opens the dialog at start (may be relative or missing) and returns at once; done runs later on another thread, once. +// The caller keeps one dialog open at a time. +void folder_pick(const char *start, FolderPickDone done, void *ctx); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/folder_pick_sdl.c b/runtime/folder_pick_sdl.c new file mode 100644 index 00000000..74a48a1c --- /dev/null +++ b/runtime/folder_pick_sdl.c @@ -0,0 +1,42 @@ +// The SDL front end of folder_pick.h: SDL_ShowOpenFolderDialog (the desktop portal, else zenity), started on the window +// thread, which runs SDL's event loop that delivers the portal's answer. +#include +#include +#include + +#include + +#include "folder_pick.h" +#include "host.h" +#include "input_sdl.h" + +typedef struct { + char start[PATH_MAX]; // absolute, or empty: the dialog's own default + FolderPickDone done; + void *ctx; +} PickReq; + +static void SDLCALL on_pick(void *userdata, const char *const *list, int filter) { + (void)filter; + PickReq *r = userdata; + if (!list) rt_log("folder_pick: the folder dialog failed (%s)", SDL_GetError()); + r->done(list && list[0] ? list[0] : NULL, r->ctx); + free(r); +} + +static void show(void *arg) { + PickReq *r = arg; + SDL_ShowOpenFolderDialog(on_pick, r, gfx_window_sdl(), r->start[0] ? r->start : NULL, false); +} + +void folder_pick(const char *start, FolderPickDone done, void *ctx) { + PickReq *r = calloc(1, sizeof *r); + if (!r) { + done(NULL, ctx); + return; + } + r->done = done; + r->ctx = ctx; + if (start && *start && !realpath(start, r->start)) r->start[0] = 0; + gfx_window_sdl_run(show, r); +} diff --git a/runtime/folder_pick_win.cpp b/runtime/folder_pick_win.cpp new file mode 100644 index 00000000..9f6f40a5 --- /dev/null +++ b/runtime/folder_pick_win.cpp @@ -0,0 +1,84 @@ +// The Win32 front end of folder_pick.h: the shell's folder dialog (IFileOpenDialog with FOS_PICKFOLDERS), owned by the +// game window, on a thread of its own so the present thread that draws the menu keeps running. +#include +#include +#include +#include + +extern "C" { +#include "folder_pick.h" +#include "host.h" +#include "input_win.h" +#include "platform/platform.h" +} + +struct PickReq { + std::string start; + FolderPickDone done; + void *ctx; +}; + +static std::wstring wide(const std::string &utf8) { + int n = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, NULL, 0); + std::wstring w(n > 0 ? n : 1, L'\0'); + if (n > 0) MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &w[0], n); + w.resize(wcslen(w.c_str())); + return w; +} + +static std::string utf8(const wchar_t *w) { + int n = WideCharToMultiByte(CP_UTF8, 0, w, -1, NULL, 0, NULL, NULL); + std::string s(n > 0 ? n : 1, '\0'); + if (n > 0) WideCharToMultiByte(CP_UTF8, 0, w, -1, &s[0], n, NULL, NULL); + s.resize(strlen(s.c_str())); + return s; +} + +// The dialog wants an absolute folder that exists; anything else leaves it at the shell's last folder. +static void set_start(IFileOpenDialog *d, const std::string &start) { + wchar_t full[MAX_PATH * 2]; + if (start.empty() || !GetFullPathNameW(wide(start).c_str(), MAX_PATH * 2, full, NULL)) return; + IShellItem *item = NULL; + if (SUCCEEDED(SHCreateItemFromParsingName(full, NULL, IID_PPV_ARGS(&item)))) { + d->SetFolder(item); + item->Release(); + } +} + +static void pick_main(void *arg) { + PickReq *r = (PickReq *)arg; + std::string chosen; + HRESULT co = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + IFileOpenDialog *d = NULL; + HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&d)); + if (SUCCEEDED(hr)) { + FILEOPENDIALOGOPTIONS opt = 0; + d->GetOptions(&opt); + d->SetOptions(opt | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST); + set_start(d, r->start); + hr = d->Show(gfx_window_handle()); + IShellItem *item = NULL; + if (SUCCEEDED(hr) && SUCCEEDED(hr = d->GetResult(&item))) { + PWSTR path = NULL; + if (SUCCEEDED(hr = item->GetDisplayName(SIGDN_FILESYSPATH, &path))) { + chosen = utf8(path); + CoTaskMemFree(path); + } + item->Release(); + } + d->Release(); + } + if (FAILED(hr) && hr != HRESULT_FROM_WIN32(ERROR_CANCELLED)) rt_log("folder_pick: the folder dialog failed (0x%08lx)", (unsigned long)hr); + r->done(chosen.empty() ? NULL : chosen.c_str(), r->ctx); + if (SUCCEEDED(co)) CoUninitialize(); + delete r; +} + +extern "C" void folder_pick(const char *start, FolderPickDone done, void *ctx) { + PickReq *r = new PickReq{start ? start : "", done, ctx}; + if (!plat_thread_spawn(pick_main, r, "folder dialog", 0)) { + rt_log("folder_pick: cannot start the dialog thread"); + done(NULL, ctx); + delete r; + } +} diff --git a/runtime/gfx_window_sdl.c b/runtime/gfx_window_sdl.c index ef869c34..eef6efe6 100644 --- a/runtime/gfx_window_sdl.c +++ b/runtime/gfx_window_sdl.c @@ -64,6 +64,8 @@ double gfx_window_sdl_density(void) { return __atomic_load_n(&g_density_milli, _ SDL_Window *gfx_window_sdl(void) { return g_win; } +void gfx_window_sdl_run(void (*fn)(void *arg), void *arg) { run_on_window(fn, arg); } + // ---- settings: size and fullscreen static char g_applied_size[32]; static int g_applied_fs; diff --git a/runtime/input_sdl.h b/runtime/input_sdl.h index bfbb44b8..ec26db86 100644 --- a/runtime/input_sdl.h +++ b/runtime/input_sdl.h @@ -11,6 +11,7 @@ extern "C" { // gfx_window_sdl.c SDL_Window *gfx_window_sdl(void); // the game window, NULL before gfx_window_start; any thread double gfx_window_sdl_density(void); // pixels per window unit (mouse positions are in units); any thread +void gfx_window_sdl_run(void (*fn)(void *arg), void *arg); // runs fn on the window thread and waits; any thread // input_sdl.c void input_sdl_window(SDL_Window *w); // the game window was created diff --git a/runtime/ui/amiibo_page.cpp b/runtime/ui/amiibo_page.cpp index f5ea39ad..4a4e44d2 100644 --- a/runtime/ui/amiibo_page.cpp +++ b/runtime/ui/amiibo_page.cpp @@ -17,6 +17,7 @@ extern "C" { #include "amiibo.h" #include "cfg.h" +#include "folder_pick.h" #include "platform/platform.h" #include "ui_text.h" } @@ -30,7 +31,6 @@ struct FileEntry { static const char kDefaultAmiiboDir[] = "./amiibo"; static std::string g_current_dir = kDefaultAmiiboDir; -static char g_dir_input[512] = "./amiibo"; static std::vector g_entries; static int g_selected_entry = -1; // The last action: a catalog id and the file or folder it names, formatted when drawn (the menu language can change) @@ -61,10 +61,24 @@ static bool iequals(std::string_view a, std::string_view b) { static void set_current_dir(const std::string &path) { g_current_dir = path; - snprintf(g_dir_input, sizeof(g_dir_input), "%s", g_current_dir.c_str()); cfg_set("TOTK_AMIIBO_DIR", g_current_dir.c_str()); } +// The host's folder dialog (folder_pick.h) answers on its own thread; the page takes the answer when it draws. +static PlatMutex g_pick_lock = PLAT_MUTEX_INIT; +static bool g_pick_open; +static bool g_pick_ready; +static std::string g_pick_path; + +static void on_folder_picked(const char *path, void *ctx) { + (void)ctx; + plat_mutex_lock(&g_pick_lock); + g_pick_ready = path != NULL; + if (path) g_pick_path = path; + g_pick_open = false; + plat_mutex_unlock(&g_pick_lock); +} + static void refresh_dir(void) { g_entries.clear(); g_selected_entry = -1; @@ -122,11 +136,21 @@ void tab_amiibo(float scale) { } else { g_current_dir = kDefaultAmiiboDir; } - snprintf(g_dir_input, sizeof(g_dir_input), "%s", g_current_dir.c_str()); refresh_dir(); s_inited = true; } + plat_mutex_lock(&g_pick_lock); + bool picking = g_pick_open; + std::string picked; + if (g_pick_ready) picked.swap(g_pick_path); + g_pick_ready = false; + plat_mutex_unlock(&g_pick_lock); + if (!picked.empty()) { + set_current_dir(picked); + refresh_dir(); + } + // Two panes: Left (Directory Browser & Tag List), Right (Amiibo Tag Details & Controls) const float content_h = ImGui::GetContentRegionAvail().y - opt_bottom(); // Continue and Quit stay visible const float left_w = ImGui::GetContentRegionAvail().x * 0.58f; @@ -136,17 +160,17 @@ void tab_amiibo(float scale) { ImGui::BeginChild("##amiibo_explorer", ImVec2(left_w, content_h), true); ImGui::TextColored(ImVec4(0.93f, 0.76f, 0.33f, 1.0f), "%s", menu_text(UI_MENU_AMIIBO_DIR)); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - if (ImGui::InputText("##amiibo_dir_input", g_dir_input, sizeof(g_dir_input), ImGuiInputTextFlags_EnterReturnsTrue)) { - set_current_dir(g_dir_input); - refresh_dir(); - } + ImGui::TextWrapped("%s", g_current_dir.c_str()); ImGui::Spacing(); + ImGui::BeginDisabled(picking); if (ImGui::Button(menu_text(UI_MENU_AMIIBO_OPEN))) { - set_current_dir(g_dir_input); - refresh_dir(); + plat_mutex_lock(&g_pick_lock); + g_pick_open = true; + plat_mutex_unlock(&g_pick_lock); + folder_pick(g_current_dir.c_str(), on_folder_picked, NULL); } + ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button(menu_text(UI_MENU_AMIIBO_DEFAULT_DIR))) { set_current_dir(kDefaultAmiiboDir); @@ -161,6 +185,11 @@ void tab_amiibo(float scale) { ImGui::TextDisabled("%s", menu_text(UI_MENU_AMIIBO_PICK)); ImGui::BeginChild("##file_list", ImVec2(0, 0), true); + if (g_status_id == UI_MENU_AMIIBO_NO_DIR) { + char missing[512]; + snprintf(missing, sizeof missing, menu_text(UI_MENU_AMIIBO_NO_DIR), g_current_dir.c_str()); + ImGui::TextWrapped("%s", missing); + } for (size_t i = 0; i < g_entries.size(); i++) { const auto &e = g_entries[i]; std::string label = e.is_dir ? e.name + "/" : e.name; diff --git a/tools/build_game.py b/tools/build_game.py index 9ecf5013..4b9b0419 100644 --- a/tools/build_game.py +++ b/tools/build_game.py @@ -115,7 +115,7 @@ if WIN: RT_FLAGS.append('-D_WIN32_WINNT=0x0A00') # -Xlinker passes the map path as one argument; -Wl, would split it at any comma in the build dir path LINK_FLAGS = ['-fuse-ld=lld', '-Wl,--stack,16777216', '-Xlinker', '"--Map=$builddir/totk.map"'] # the map feeds tools/prof_report.py - LINK_LIBS = ['-lpsapi', '-lbcrypt', '-lvulkan-1', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lSDL3', '-lopus', '-l:libvpx.a', '-l:libzstd.a', '-l:libstdc++.a', '-l:libwinpthread.a'] # Vulkan back end; libvpx static (movies); libzstd static (resolution.c) + LINK_LIBS = ['-lpsapi', '-lbcrypt', '-lvulkan-1', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lole32', '-lshell32', '-luuid', '-lSDL3', '-lopus', '-l:libvpx.a', '-l:libzstd.a', '-l:libstdc++.a', '-l:libwinpthread.a'] # Vulkan back end; libvpx static (movies); libzstd static (resolution.c) VMA_INCLUDE = UCRT.parent / 'include' / 'vma' # vk_mem_alloc.h; MSYS2 package vulkan-memory-allocator else: # -no-pie: the lifted modules' function tables (hundreds of thousands of pointers) need no load-time relocation. @@ -151,7 +151,7 @@ def vma_include(): # --toolchain: an llvm-mingw tree. The libraries, headers and DLLs MSYS2 supplies come from the deps directory instead. -LLVM_LINK_LIBS = ['-lpsapi', '-lbcrypt', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lvulkan-1', '-lSDL3', '-lopus', +LLVM_LINK_LIBS = ['-lpsapi', '-lbcrypt', '-luser32', '-lgdi32', '-ldwmapi', '-limm32', '-lwinmm', '-lole32', '-lshell32', '-luuid', '-lvulkan-1', '-lSDL3', '-lopus', '-l:libvpx.a', '-l:libzstd.a', '-l:libc++.a', '-l:libc++abi.a', '-l:libunwind.a', '-l:libwinpthread.a'] DEFAULT_DEPS = ROOT / 'toolchain' / 'deps-mingw' # Runtime sources compiled per install, not in the library: romfs.c includes romfs_config.h, patches.c and ov_zstd.c From 69104be9b847f6bcf776a7e41a2f132abf41d24c Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 17:43:29 +0300 Subject: [PATCH 38/39] Hold the amiibo reader on searching across the game's detection restarts While TotK waits for a tag it stops and starts detection several times a second, tens with the menu open: the Amiibo tab's reader line flipped between ready and searching and the log took two lines per restart. The tab now shows searching for a second after a stop (amiibo_get_reader_state; the game still reads amiibo_get_device_state), and the log names the first three restarts, then a count every 200th. Co-Authored-By: Claude Opus 5.5 --- runtime/amiibo.c | 21 +++++++++++++++++++-- runtime/amiibo.h | 3 +++ runtime/ui/amiibo_page.cpp | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/runtime/amiibo.c b/runtime/amiibo.c index 8b2c6a95..e44773d2 100644 --- a/runtime/amiibo.c +++ b/runtime/amiibo.c @@ -107,17 +107,25 @@ bool amiibo_is_device_present(void) { return res; } +// While the game waits for a tag it stops and starts detection again several times a second (tens with the menu open), +// so the log names the first few and then a count every 200th start. +static uint64_t g_detect_starts; +static uint64_t g_detect_stop_ns; // the last stop: the menu holds "searching" across the game's restarts + void amiibo_start_detection(void) { plat_mutex_lock(&g_amiibo_lock); g_detecting = true; - rt_log("amiibo: start detection"); + uint64_t n = ++g_detect_starts; + if (n <= 3) rt_log("amiibo: start detection"); + else if (n % 200 == 0) rt_log("amiibo: detection started %llu times so far", (unsigned long long)n); plat_mutex_unlock(&g_amiibo_lock); } void amiibo_stop_detection(void) { plat_mutex_lock(&g_amiibo_lock); + if (g_detecting) g_detect_stop_ns = plat_now_ns(); g_detecting = false; - rt_log("amiibo: stop detection"); + if (g_detect_starts <= 3) rt_log("amiibo: stop detection"); plat_mutex_unlock(&g_amiibo_lock); } @@ -176,6 +184,15 @@ AmiiboDeviceState amiibo_get_device_state(void) { return AMIIBO_DEV_STATE_INITIALIZED; } +AmiiboDeviceState amiibo_get_reader_state(void) { + AmiiboDeviceState state = amiibo_get_device_state(); + if (state != AMIIBO_DEV_STATE_INITIALIZED) return state; + plat_mutex_lock(&g_amiibo_lock); + bool recent = g_detect_stop_ns && plat_now_ns() - g_detect_stop_ns < 1000000000ull; + plat_mutex_unlock(&g_amiibo_lock); + return recent ? AMIIBO_DEV_STATE_SEARCHING : state; +} + bool amiibo_load_file(const char *path) { if (!path || !*path) return false; uint64_t size = 0; diff --git a/runtime/amiibo.h b/runtime/amiibo.h index 11597f8a..5a3e96d4 100644 --- a/runtime/amiibo.h +++ b/runtime/amiibo.h @@ -51,6 +51,9 @@ bool amiibo_is_mounted(void); // State query AmiiboDeviceState amiibo_get_device_state(void); +// The state the menu shows: searching also within a second after a stop, since the game restarts detection many times +// a second while it waits for a tag. The game reads amiibo_get_device_state. +AmiiboDeviceState amiibo_get_reader_state(void); // File loading & triggering actions bool amiibo_load_file(const char *path); diff --git a/runtime/ui/amiibo_page.cpp b/runtime/ui/amiibo_page.cpp index 4a4e44d2..2f2ed996 100644 --- a/runtime/ui/amiibo_page.cpp +++ b/runtime/ui/amiibo_page.cpp @@ -227,7 +227,7 @@ void tab_amiibo(float scale) { ImGui::TextColored(ImVec4(0.93f, 0.76f, 0.33f, 1.0f), "%s", menu_text(UI_MENU_AMIIBO_READER)); ImGui::Separator(); - AmiiboDeviceState state = amiibo_get_device_state(); + AmiiboDeviceState state = amiibo_get_reader_state(); UiTextId state_id = UI_MENU_AMIIBO_STATE_IDLE; ImVec4 state_col = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); switch (state) { From c38e0ca13d2bceb0590b195f69a010315913850d Mon Sep 17 00:00:00 2001 From: Valiantsin Dzerakh Date: Mon, 2 Nov 2026 18:01:26 +0300 Subject: [PATCH 39/39] Up version 0.3.4 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9e11b32f..42045aca 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.3.4