Files
totk-pc-recomp/runtime/ui/menu.cpp
T
Valiantsin DzerakhandClaude Opus 5.5 e6116bc8bb Draw the full performance overlay from perfmon: CPU, GPU and memory load, temperatures, frame times with a graph and the render thread's waits
The panel's columns are as wide as the widest value they can show and the numbers are right-aligned, so the panel keeps its width while the numbers change. It sits in front of the open menu. FPS mode keeps its one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:55:08 +03:00

854 lines
39 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The in-game menu core: Dear ImGui state, the event queue the window adapter fills, gamepad navigation, the Vulkan back
// end on our device and the menu's pages. Free of any window system (menu.h). ImGui runs on the present path only,
// under gfx_window.c's present lock; the event functions only queue.
//
// The Vulkan set-up follows LAS Recomp's runtime/nvn/vulkan/imgui_glue.cpp (https://github.com/peiikai/LASRecomp @
// da35b83a9611d046a9a681aae668d7085954d37f, MIT, Copyright (c) 2026 Peiikai): dynamic rendering into the swapchain
// image, an internal descriptor pool, vertex buffers rotated over more frames than are in flight.
//
// The look (colours, rounding, the tab bar with L / R and Q / E, the window over a dimmed game) follows
// SavageWindRecomp's runtime/ui/menu.mm (https://github.com/Revivedx/SavageWindRecomp @
// 575ad88e02f9a89a9a885d90039d48af33c982f4, MIT, Copyright (c) 2026 Revivedx and the Savage Wind Recomp contributors,
// Copyright (c) 2026 Peiikai); the pages are rows of option_rows.cpp.
#include <atomic>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "imgui.h"
#include "imgui_internal.h" // SetFocusID, NavCursorVisible
#include "backends/imgui_impl_vulkan.h"
#include "ui/option_rows.h"
extern "C" {
#include "cfg.h"
#include "input.h"
#include "input_map.h"
#include "perfmon.h"
#include "platform/platform.h"
#include "ui/menu.h"
#include "ui_text.h"
// host.h and vk.h pull in cpu.h, which is C only: the three functions the menu needs from them, as declared there.
void rt_log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
void vk_queue_lock(void);
void vk_queue_unlock(void);
}
// Roboto Medium (Apache-2.0, ui/fonts/LICENSE-Roboto.txt) with Latin and Cyrillic glyphs.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wc23-extensions" // #embed in C++
static const unsigned char roboto_ttf[] = {
#embed "fonts/Roboto-Medium.ttf"
};
#pragma clang diagnostic pop
// ---- open state, any thread
static std::atomic<int> g_open; // the menu is open
static std::atomic<int> g_reset; // opened or closed since the last laid-out frame: drop stale input
// g_open, the input block and the log line change together under open_lock, so they cannot disagree when the window thread
// (F1, Esc), the polling thread (combo) and the present path (Continue, B) race.
static PlatMutex open_lock = PLAT_MUTEX_INIT;
// want 0 or 1 sets the state; -1 flips it. Returns the new state.
static int change_open(int want) {
plat_mutex_lock(&open_lock);
int open = want < 0 ? !g_open.load() : want;
if (g_open.load() != open) {
g_open.store(open);
g_reset.store(1);
input_menu(open);
rt_log("menu: %s", open ? "open" : "closed");
}
plat_mutex_unlock(&open_lock);
return open;
}
static void set_open(int open) { change_open(open); }
static int g_failed_flag();
extern "C" int menu_is_open(void) { return g_open.load(std::memory_order_relaxed); }
extern "C" void menu_toggle(void) {
if (!g_failed_flag()) change_open(-1);
}
// ---- events from the window adapter, queued while the menu is open; the present path hands them to ImGui
enum { EV_KEY, EV_POS, EV_BUTTON, EV_WHEEL, EV_FOCUS, EV_MAX = 256 };
struct Ev { int type, a, b; float x, y; };
static PlatMutex ev_lock = PLAT_MUTEX_INIT;
static Ev ev_q[EV_MAX];
static int ev_n;
static void push(Ev e) {
if (!menu_is_open()) return;
plat_mutex_lock(&ev_lock);
if (e.type == EV_POS && ev_n && ev_q[ev_n - 1].type == EV_POS) ev_q[ev_n - 1] = e; // only the newest position
else if (ev_n < EV_MAX) ev_q[ev_n++] = e;
plat_mutex_unlock(&ev_lock);
}
extern "C" void menu_event_key(int key, int down) { push({EV_KEY, key, down, 0, 0}); }
extern "C" void menu_event_mouse_pos(float x, float y) { push({EV_POS, 0, 0, x, y}); }
extern "C" void menu_event_mouse_button(int button, int down) { push({EV_BUTTON, button, down, 0, 0}); }
extern "C" void menu_event_wheel(float dx, float dy) { push({EV_WHEEL, 0, 0, dx, dy}); }
extern "C" void menu_event_focus(int focused) { push({EV_FOCUS, focused, 0, 0, 0}); }
static void drain_events(ImGuiIO &io) {
Ev q[EV_MAX];
plat_mutex_lock(&ev_lock);
int n = ev_n;
for (int i = 0; i < n; i++) q[i] = ev_q[i];
ev_n = 0;
plat_mutex_unlock(&ev_lock);
for (int i = 0; i < n; i++) {
const Ev &e = q[i];
switch (e.type) {
case EV_KEY: io.AddKeyEvent((ImGuiKey)e.a, e.b != 0); break;
case EV_POS: io.AddMousePosEvent(e.x, e.y); break;
case EV_BUTTON: io.AddMouseButtonEvent(e.a, e.b != 0); break;
case EV_WHEEL: io.AddMouseWheelEvent(e.x, e.y); break;
case EV_FOCUS: io.AddFocusEvent(e.a != 0); break;
}
}
}
// ---- gamepad navigation: the Switch buttons of our SDL3 gamepad to ImGui's gamepad keys. Nintendo layout: A (east)
// activates, B (south) cancels.
static const struct { int bit; ImGuiKey key; } pad_keys[] = {
{B_A, ImGuiKey_GamepadFaceDown}, {B_B, ImGuiKey_GamepadFaceRight}, {B_X, ImGuiKey_GamepadFaceUp},
{B_Y, ImGuiKey_GamepadFaceLeft}, {B_L, ImGuiKey_GamepadL1}, {B_R, ImGuiKey_GamepadR1},
{B_ZL, ImGuiKey_GamepadL2}, {B_ZR, ImGuiKey_GamepadR2}, {B_LEFT, ImGuiKey_GamepadDpadLeft},
{B_UP, ImGuiKey_GamepadDpadUp}, {B_RIGHT, ImGuiKey_GamepadDpadRight}, {B_DOWN, ImGuiKey_GamepadDpadDown},
};
static uint64_t pad_prev; // buttons at the last frame; a button held when the menu opened counts once released
static float stick_prev[4]; // left stick left, right, up, down as ImGui analog values
static void pad_events(ImGuiIO &io, int reset) {
uint64_t b;
int32_t lx, ly;
int have = input_pad_read(&b, &lx, &ly);
if (have) io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
else io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad;
if (reset) {
pad_prev = b;
for (float &s : stick_prev) s = 0;
return;
}
for (const auto &k : pad_keys) {
uint64_t m = 1ull << k.bit;
if ((b ^ pad_prev) & m) io.AddKeyEvent(k.key, (b & m) != 0);
}
pad_prev = b;
const float dead = 0.3f;
float v[4] = {-lx / 32767.0f, lx / 32767.0f, ly / 32767.0f, -ly / 32767.0f};
static const ImGuiKey sk[4] = {ImGuiKey_GamepadLStickLeft, ImGuiKey_GamepadLStickRight, ImGuiKey_GamepadLStickUp,
ImGuiKey_GamepadLStickDown};
for (int i = 0; i < 4; i++) {
float a = v[i] > dead ? (v[i] - dead) / (1 - dead) : 0;
if (a != stick_prev[i]) io.AddKeyAnalogEvent(sk[i], a > 0.1f, a);
stick_prev[i] = a;
}
}
// ---- ImGui and its Vulkan back end (present path)
static int g_ctx_ok; // context made
static std::atomic<int> g_failed; // set-up failed for good (the menu stays closed)
static int g_failed_flag() { return g_failed.load(std::memory_order_relaxed); }
static int g_vk_ok; // back end initialised for g_vk_fmt
static VkFormat g_vk_fmt;
static float g_font_scale; // the scale of the uploaded font atlas; 0 when none is uploaded
static float g_style_scale; // the scale the style was last sized for
static ImGuiStyle g_base_style; // the menu's style at 1080 lines
static uint64_t g_last_ns;
static uint64_t g_cost_ns, g_cost_frames; // CPU time of layout and recording while open, logged per 600 frames
// Sizes are tuned for 1080 lines and follow the window's height; under 720 lines they stay at the 720 size.
static float menu_scale(uint32_t h) { return (h >= 720 ? h : 720) / 1080.0f; }
// SavageWindRecomp's colours: dark green panels, gold accents.
static void style_init(ImGuiStyle &s) {
ImGui::StyleColorsDark(&s);
s.WindowRounding = 14; s.FrameRounding = 8; s.GrabRounding = 8; s.TabRounding = 8; s.ChildRounding = 10;
s.WindowPadding = ImVec2(28, 22); s.FramePadding = ImVec2(14, 8); s.ItemSpacing = ImVec2(14, 12);
s.WindowBorderSize = 0; s.ScrollbarSize = 18;
ImVec4 *c = s.Colors;
ImVec4 gold(0.93f, 0.76f, 0.33f, 1), green(0.15f, 0.36f, 0.25f, 1);
c[ImGuiCol_WindowBg] = ImVec4(0.06f, 0.09f, 0.08f, 0.94f);
c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0);
c[ImGuiCol_Text] = ImVec4(0.96f, 0.94f, 0.88f, 1);
c[ImGuiCol_TextDisabled] = ImVec4(0.62f, 0.64f, 0.58f, 1);
c[ImGuiCol_Button] = ImVec4(0.14f, 0.22f, 0.18f, 1);
c[ImGuiCol_ButtonHovered] = green;
c[ImGuiCol_ButtonActive] = ImVec4(0.21f, 0.50f, 0.34f, 1);
c[ImGuiCol_Tab] = ImVec4(0.12f, 0.18f, 0.15f, 1);
c[ImGuiCol_TabHovered] = green;
c[ImGuiCol_TabSelected] = ImVec4(0.21f, 0.44f, 0.31f, 1);
c[ImGuiCol_TabSelectedOverline] = gold;
c[ImGuiCol_NavCursor] = gold;
c[ImGuiCol_Separator] = ImVec4(0.30f, 0.36f, 0.30f, 1);
}
static void make_context(void) {
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard | ImGuiConfigFlags_NavEnableGamepad;
io.BackendPlatformName = "totk_menu";
style_init(g_base_style);
ImGui::GetStyle() = g_base_style;
g_ctx_ok = 1;
}
static void vk_down(void) {
if (!g_vk_ok) return;
ImGui_ImplVulkan_Shutdown();
g_vk_ok = 0;
g_font_scale = 0;
}
static int vk_up(const MenuTarget *t) {
if (g_vk_ok && g_vk_fmt == t->format) return 1;
if (g_vk_ok) { // the swapchain changed format: pipelines for the new one
vk_queue_lock();
vkQueueWaitIdle(t->queue);
vk_queue_unlock();
vk_down();
}
ImGui_ImplVulkan_InitInfo ii = {};
ii.ApiVersion = VK_API_VERSION_1_3;
ii.Instance = t->instance;
ii.PhysicalDevice = t->phys;
ii.Device = t->device;
ii.QueueFamily = t->queue_family;
ii.Queue = t->queue;
ii.DescriptorPoolSize = 16;
ii.MinImageCount = 2;
ii.ImageCount = 4; // vertex buffers rotate per recorded frame: more than gfx_window.c's 2 frames in flight
ii.MSAASamples = VK_SAMPLE_COUNT_1_BIT;
ii.UseDynamicRendering = true;
g_vk_fmt = t->format;
ii.PipelineRenderingCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO;
ii.PipelineRenderingCreateInfo.colorAttachmentCount = 1;
ii.PipelineRenderingCreateInfo.pColorAttachmentFormats = &g_vk_fmt;
if (!ImGui_ImplVulkan_Init(&ii)) return 0;
g_vk_ok = 1;
return 1;
}
// Roboto Medium (Latin, Cyrillic, dashes and quotes) at 26 px per 1080 lines, with the glyphs of the Japanese, Korean
// and Chinese language names merged in from a system font when there is one (each language is named in its own script).
// Made again when the window height changes; the old texture goes after the queue is idle (CreateFontsTexture).
static const ImWchar kRanges[] = {0x0020, 0x00FF, 0x0400, 0x052F, 0x2010, 0x2027, 0};
static const struct { const char *lang; UiTextId name; } kCjk[] = {
{"ja", UI_MENU_LANGNAME_JA}, {"ko", UI_MENU_LANGNAME_KO}, {"zh-Hans", UI_MENU_LANGNAME_ZH_HANS},
{"zh-Hant", UI_MENU_LANGNAME_ZH_HANT},
};
static int fonts_up(float scale) {
enum { N = sizeof kCjk / sizeof *kCjk };
ImFontAtlas *fa = ImGui::GetIO().Fonts;
fa->Clear();
ImFontConfig fc;
fc.FontDataOwnedByAtlas = false;
fa->AddFontFromMemoryTTF((void *)roboto_ttf, (int)sizeof roboto_ttf, 26.0f * scale, &fc, kRanges);
uint8_t *data[N] = {};
ImVector<ImWchar> ranges[N];
int merged = 0;
for (int i = 0; i < N; i++) {
char path[512];
uint64_t size = 0;
if (!plat_font_path(kCjk[i].lang, path, sizeof path) || !(data[i] = plat_file_read(path, &size))) continue;
merged++;
ImFontGlyphRangesBuilder b;
b.AddText(ui_text_in("en", kCjk[i].name));
b.BuildRanges(&ranges[i]);
ImFontConfig mc;
mc.MergeMode = true;
mc.FontDataOwnedByAtlas = false;
fa->AddFontFromMemoryTTF(data[i], (int)size, 26.0f * scale, &mc, ranges[i].Data);
}
vk_queue_lock(); // the upload submits on the queue and waits for it
int ok = ImGui_ImplVulkan_CreateFontsTexture();
vk_queue_unlock();
fa->ClearInputData(); // the glyphs are in the texture: the font files and the pixels are not needed again
fa->ClearTexData();
for (uint8_t *d : data) free(d);
rt_log("menu: font atlas at %.1f px, language names from %d of %d system fonts", 26.0f * scale, merged, (int)N);
g_font_scale = ok ? scale : 0;
return ok;
}
extern "C" void menu_vk_release(void) {
if (g_ctx_ok) vk_down();
}
// ---- test hooks
// TOTK_MENU_TEST=<frame>[:<frames per page>]: at that presented frame the menu opens on its first tab; each tab stays
// <frames per page> frames (default 30) and its last frame's swapchain image goes to TOTK_MENU_TEST_DIR (default: the
// working directory) as menu-test-<frame>-<page>.png; after the last tab the menu closes.
// TOTK_MENU_TEST_SET=<frame>:<key>=<value>[,<key>=<value>...][;<frame>:...]: cfg_set of each key (TOTK_ prefix
// optional) at that presented frame; changes live settings with no input, menu open or not. totk.ini is written only by
// the pseudo-key SAVE=1 (cfg_save, as a row change does; a failed write shows the menu's save note).
enum { TAB_SCREEN, TAB_GAME, TAB_SOUND, TAB_CONTROLS, TAB_MENU, TAB_COUNT };
enum { TEST_PAGE_FRAMES = 30, TEST_SETS = 16 };
struct TestSet { uint64_t frame; char key[64], value[64]; };
static struct {
int parsed;
uint64_t at; // TOTK_MENU_TEST frame; 0 none
uint64_t per_page;
uint64_t opened; // the frame the hook opened the menu; 0 not yet
int page; // the tab being shot; -1 none
TestSet sets[TEST_SETS];
int n_sets, next_set; // TOTK_MENU_TEST_SET, in frame order
} g_test = {0, 0, 0, 0, -1, {}, 0, 0};
static void test_parse(void) {
g_test.parsed = 1;
if (const char *e = cfg_get("TOTK_MENU_TEST")) {
long long at = atoll(e), per = 0;
if (const char *c = strchr(e, ':')) per = atoll(c + 1);
g_test.at = at > 0 ? (uint64_t)at : 0;
g_test.per_page = per > 0 ? (uint64_t)per : TEST_PAGE_FRAMES;
}
const char *s = cfg_get("TOTK_MENU_TEST_SET");
while (s && *s) { // groups "<frame>:<key>=<value>,..." separated by ';'
char *end;
unsigned long long f = strtoull(s, &end, 10);
if (*end != ':') break;
s = end + 1;
while (*s && *s != ';' && g_test.n_sets < TEST_SETS) {
size_t n = strcspn(s, ",;");
const char *eq = (const char *)memchr(s, '=', n);
if (eq) {
TestSet &t = g_test.sets[g_test.n_sets++];
t.frame = f;
int kn = (int)(eq - s), vn = (int)(s + n - eq - 1);
snprintf(t.key, sizeof t.key, "%s%.*s", strncmp(s, "TOTK_", 5) ? "TOTK_" : "", kn, s);
snprintf(t.value, sizeof t.value, "%.*s", vn, eq + 1);
}
s += n;
if (*s == ',') s++;
}
while (*s && *s != ';') s++;
if (*s == ';') s++;
}
if (g_test.at || g_test.n_sets)
rt_log("menu: test hook: open at frame %llu, %llu frames per page, %d settings changes",
(unsigned long long)g_test.at, (unsigned long long)g_test.per_page, g_test.n_sets);
}
static void test_apply_sets(uint64_t frame) {
for (; g_test.next_set < g_test.n_sets && g_test.sets[g_test.next_set].frame <= frame; g_test.next_set++) {
const TestSet &t = g_test.sets[g_test.next_set];
rt_log("menu: test hook: frame %llu: %s=%s", (unsigned long long)frame, t.key, t.value);
if (!strcmp(t.key, "TOTK_SAVE")) rt_log("menu: test hook: cfg_save %s", cfg_save() ? "wrote" : "failed");
else cfg_set(t.key, t.value);
}
}
// ---- the lists the Screen and Game tabs build
enum { MAX_GPUS = 8 };
static char g_gpu_names[MAX_GPUS][VK_MAX_PHYSICAL_DEVICE_NAME_SIZE];
static int g_gpu_n = -1; // -1: not listed yet
// The Vulkan devices in vkEnumeratePhysicalDevices order, which TOTK_GPU_INDEX counts in (vk_device.c pick_device).
static void list_gpus(VkInstance inst) {
if (g_gpu_n >= 0) return;
VkPhysicalDevice pd[MAX_GPUS];
uint32_t n = MAX_GPUS;
if (vkEnumeratePhysicalDevices(inst, &n, pd) < 0) n = 0; // VK_INCOMPLETE keeps the first MAX_GPUS
for (uint32_t i = 0; i < n; i++) {
VkPhysicalDeviceProperties p;
vkGetPhysicalDeviceProperties(pd[i], &p);
snprintf(g_gpu_names[i], sizeof g_gpu_names[i], "%s", p.deviceName);
}
g_gpu_n = (int)n;
}
// Window sizes: the common 16:9 sizes that fit the monitor, the 1920x1080 default and the current size.
static const int kSizes[][2] = {{1280, 720}, {1366, 768}, {1600, 900}, {1920, 1080}, {2560, 1440}, {3200, 1800},
{3840, 2160}};
enum { MAX_SIZES = sizeof kSizes / sizeof *kSizes + 1 };
static void row_window_size(void) {
int sz[MAX_SIZES][2], n = 0, mw = 0, mh = 0, cw = 0, ch = 0;
int have = gfx_window_monitor_size(&mw, &mh);
int cur_ok = sscanf(cfg_value("TOTK_WINDOW_SIZE"), "%dx%d", &cw, &ch) == 2;
bool listed = false;
for (size_t i = 0; i < sizeof kSizes / sizeof *kSizes; i++) {
int w = kSizes[i][0], h = kSizes[i][1];
bool cur = cur_ok && w == cw && h == ch;
if (have && (w > mw || h > mh) && !cur && !(w == 1920 && h == 1080)) continue;
listed |= cur;
sz[n][0] = w, sz[n][1] = h, n++;
}
if (cur_ok && !listed) { // the current size where its area puts it
int at = n;
while (at > 0 && (long)sz[at - 1][0] * sz[at - 1][1] > (long)cw * ch) at--;
memmove(sz[at + 1], sz[at], sizeof sz[0] * (n - at));
sz[at][0] = cw, sz[at][1] = ch, n++;
}
char vals[MAX_SIZES][24], shown[MAX_SIZES][32];
const char *pv[MAX_SIZES], *pn[MAX_SIZES];
for (int i = 0; i < n; i++) {
snprintf(vals[i], sizeof vals[i], "%dx%d", sz[i][0], sz[i][1]);
snprintf(shown[i], sizeof shown[i], "%d × %d", sz[i][0], sz[i][1]); // U+00D7, the multiplication sign
pv[i] = vals[i];
pn[i] = shown[i];
}
opt_choice_list("TOTK_WINDOW_SIZE", UI_MENU_WINDOW_SIZE, pv, pn, n, UI_MENU_WINDOW_SIZE_DESC);
}
static void row_gpu(void) {
const char *vals[MAX_GPUS + 1], *names[MAX_GPUS + 1];
char idx[MAX_GPUS][4];
vals[0] = "";
names[0] = menu_text(UI_MENU_GPU_AUTO);
for (int i = 0; i < g_gpu_n; i++) {
snprintf(idx[i], sizeof idx[i], "%d", i);
vals[i + 1] = idx[i];
names[i + 1] = g_gpu_names[i];
}
opt_choice_list("TOTK_GPU_INDEX", UI_MENU_GPU, vals, names, g_gpu_n + 1, UI_MENU_GPU_DESC);
}
// The game language: one entry per catalog language (a Switch code that names it), each language under its own name.
static void row_game_lang(void) {
static const struct { const char *code; UiTextId name; } langs[] = {
{"en-US", UI_MENU_LANGNAME_EN}, {"ja", UI_MENU_LANGNAME_JA}, {"fr", UI_MENU_LANGNAME_FR},
{"de", UI_MENU_LANGNAME_DE}, {"it", UI_MENU_LANGNAME_IT}, {"es", UI_MENU_LANGNAME_ES},
{"nl", UI_MENU_LANGNAME_NL}, {"pt", UI_MENU_LANGNAME_PT}, {"ru", UI_MENU_LANGNAME_RU},
{"zh-CN", UI_MENU_LANGNAME_ZH_HANS}, {"zh-TW", UI_MENU_LANGNAME_ZH_HANT}, {"ko", UI_MENU_LANGNAME_KO},
};
enum { N = sizeof langs / sizeof *langs + 1 };
const char *vals[N], *names[N];
vals[0] = "";
names[0] = menu_text(UI_MENU_GAME_LANG_AUTO);
const char *now = cfg_value("TOTK_LANG");
int cur = *now ? -1 : 0;
for (int i = 1; i < N; i++) {
vals[i] = langs[i - 1].code;
names[i] = menu_text(langs[i - 1].name);
if (*now && !strcmp(ui_text_lang(now), ui_text_lang(vals[i]))) cur = i; // en-GB is English too
}
opt_choice_list("TOTK_LANG", UI_MENU_GAME_LANG, vals, names, N, UI_MENU_GAME_LANG_DESC, cur);
}
// The frame limit (frame_rate.c): unset, the older fps60 = 0 still means 30. The monitor choice shows the rate it gives.
static void row_fps_limit() {
static const char *const vals[] = {"30", "60", "120", "144", "display"};
static const UiTextId ids[] = {UI_MENU_FPS_30, UI_MENU_FPS_60, UI_MENU_FPS_120, UI_MENU_FPS_144, UI_MENU_FPS_DISPLAY};
enum { N = sizeof vals / sizeof *vals };
const char *names[N];
for (int i = 0; i < N; i++) names[i] = menu_text(ids[i]);
static char display[64];
double hz = gfx_window_refresh_hz();
if (hz > 0) {
snprintf(display, sizeof display, menu_text(UI_MENU_FPS_DISPLAY_HZ), hz);
names[N - 1] = display;
}
const char *set = cfg_get("TOTK_FPS_LIMIT"), *old = cfg_get("TOTK_FPS60");
int cur = (!set || !*set) && old && !strcmp(old, "0") ? 0 : -1;
opt_choice_list("TOTK_FPS_LIMIT", UI_MENU_FPS_LIMIT, vals, names, N, UI_MENU_FPS_LIMIT_DESC, cur);
}
// ---- the tabs
static void tab_screen(float scale) {
opt_begin("##screen", scale);
opt_toggle("TOTK_FULLSCREEN", UI_MENU_FULLSCREEN, UI_MENU_FULLSCREEN_DESC);
row_window_size();
static const UiTextId present[] = {UI_MENU_PRESENT_MAILBOX, UI_MENU_PRESENT_VSYNC, UI_MENU_PRESENT_IMMEDIATE};
opt_choice("TOTK_VK_PRESENT", UI_MENU_PRESENT, present, 3, UI_MENU_PRESENT_DESC);
row_gpu();
static const UiTextId perf[] = {UI_MENU_PERF_OFF, UI_MENU_PERF_FPS, UI_MENU_PERF_FULL};
opt_choice("TOTK_PERF_OVERLAY", UI_MENU_PERF_OVERLAY, perf, 3, UI_MENU_PERF_OVERLAY_DESC);
opt_end();
}
static void tab_game(float scale) {
opt_begin("##game", scale);
row_fps_limit();
opt_toggle("TOTK_REALTIME", UI_MENU_REALTIME, UI_MENU_REALTIME_DESC);
opt_toggle("TOTK_CLOTH_REALTIME", UI_MENU_CLOTH_REALTIME, UI_MENU_CLOTH_REALTIME_DESC);
opt_toggle("TOTK_HIGHFPS_FIX", UI_MENU_HIGHFPS_FIX, UI_MENU_HIGHFPS_FIX_DESC);
row_game_lang();
static const UiTextId mode[] = {UI_MENU_MODE_DOCKED, UI_MENU_MODE_HANDHELD};
opt_choice("TOTK_OPERATION_MODE", UI_MENU_OPERATION_MODE, mode, 2, UI_MENU_OPERATION_MODE_DESC);
opt_end();
}
static void tab_sound(float scale) {
opt_begin("##sound", scale);
opt_slider("TOTK_VOLUME", UI_MENU_VOLUME, 0, 100, UI_MENU_VOLUME_DESC, 5, "%.0f %%");
opt_end();
}
static void tab_controls(float scale) {
opt_begin("##controls", scale);
opt_toggle("TOTK_MOTION", UI_MENU_MOTION, UI_MENU_MOTION_DESC);
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_MOUSE_SENS", UI_MENU_MOUSE_SENS, 0, 10, UI_MENU_MOUSE_SENS_DESC, 0.1f, "%.1f");
opt_end();
}
static void tab_menu(float scale) {
opt_begin("##menu", scale);
static const UiTextId langs[] = {UI_MENU_MENU_LANG_AUTO, UI_MENU_LANGNAME_EN, UI_MENU_LANGNAME_RU};
opt_choice("TOTK_MENU_LANG", UI_MENU_MENU_LANG, langs, 3, UI_MENU_MENU_LANG_DESC);
opt_end();
}
static const UiTextId kTabNames[TAB_COUNT] = {UI_MENU_TAB_SCREEN, UI_MENU_TAB_GAME, UI_MENU_TAB_SOUND,
UI_MENU_TAB_CONTROLS, UI_MENU_TAB_MENU};
static int g_tab; // the tab shown
static int g_tab_request = -1; // select this tab at the next layout; -1 none
static int g_focus_page = -1; // this tab's first row takes the cursor once it is shown; -1 none
// ---- the window: title, tabs (L / R or Q / E switch), the page, and the bottom row with "Continue" and "Quit game".
// B on the gamepad and Esc close it.
static void layout_menu(uint32_t w, uint32_t h, float scale) {
ImGui::GetBackgroundDrawList()->AddRectFilled(ImVec2(0, 0), ImVec2((float)w, (float)h), IM_COL32(0, 0, 0, 120));
float ww = fminf(w * 0.88f, 1400 * scale), wh = fminf(h * 0.86f, 820 * scale);
ImGui::SetNextWindowPos(ImVec2(w * 0.5f, h * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(ww, wh), ImGuiCond_Always);
ImGui::Begin("##menu", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings);
ImGui::TextColored(ImVec4(0.93f, 0.76f, 0.33f, 1), "%s", menu_text(UI_MENU_TITLE));
int step = ImGui::IsKeyPressed(ImGuiKey_GamepadR1, false) || ImGui::IsKeyPressed(ImGuiKey_E, false) ? 1
: ImGui::IsKeyPressed(ImGuiKey_GamepadL1, false) || ImGui::IsKeyPressed(ImGuiKey_Q, false) ? -1
: 0;
if (step) g_tab_request = (g_tab + step + TAB_COUNT) % TAB_COUNT;
if (g_tab_request >= 0) g_focus_page = g_tab_request; // the new page's first row takes the cursor
const ImGuiStyle &st = ImGui::GetStyle();
float bottom = ImGui::GetFrameHeight() + st.ItemSpacing.y * 2;
if (ImGui::BeginTabBar("##tabs", ImGuiTabBarFlags_NoTabListScrollingButtons)) {
for (int k = 0; k < TAB_COUNT; k++) {
char label[128];
snprintf(label, sizeof label, " %s ###tab%d", menu_text(kTabNames[k]), k); // the id stays per language
if (!ImGui::BeginTabItem(label, nullptr, g_tab_request == k ? ImGuiTabItemFlags_SetSelected : 0)) continue;
g_tab = k;
if (g_focus_page == k) {
opt_focus_first();
g_focus_page = -1;
}
ImGui::Spacing();
ImGui::BeginChild("##page", ImVec2(0, -bottom), ImGuiChildFlags_NavFlattened);
switch (k) {
case TAB_SCREEN: tab_screen(scale); break;
case TAB_GAME: tab_game(scale); break;
case TAB_SOUND: tab_sound(scale); break;
case TAB_CONTROLS: tab_controls(scale); break;
case TAB_MENU: tab_menu(scale); break;
}
ImGui::EndChild();
ImGui::EndTabItem();
}
g_tab_request = -1;
ImGui::EndTabBar();
}
// bottom row: the save status on the left, the two buttons on the right
ImGui::Spacing();
float y = ImGui::GetCursorPosY();
if (!cfg_last_save_ok()) { // stays until a save succeeds; the settings hold for the session
ImGui::SetCursorPosY(y + st.FramePadding.y);
ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1), "%s", menu_text(UI_MENU_SAVE_FAILED));
}
const char *cont = menu_text(UI_MENU_CONTINUE), *quit = menu_text(UI_MENU_QUIT);
float bw = fmaxf(ImGui::CalcTextSize(cont).x, ImGui::CalcTextSize(quit).x) + st.FramePadding.x * 4;
ImGui::SetCursorPos(ImVec2(ImGui::GetWindowWidth() - st.WindowPadding.x - bw * 2 - st.ItemSpacing.x, y));
if (ImGui::Button(cont, ImVec2(bw, 0))) set_open(0);
ImGui::SameLine();
if (ImGui::Button(quit, ImVec2(bw, 0))) gfx_window_request_close();
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false)) set_open(0);
ImGui::End();
}
static int g_save_pending; // a changed setting still waits for its write to totk.ini (opt_save_tick)
// ---- the performance overlay (TOTK_PERF_OVERLAY), in the top right corner. "fps": the frame rate over the presents of
// the last half second. "full": a panel of perfmon.c's numbers, in the style of SavageWindRecomp's perf_overlay_draw
// (menu.mm): the machine's load and temperatures, the frame rate and frame times with a graph, the render thread's waits.
// Off, it costs a cfg_version load per present.
static unsigned g_perf_ver = ~0u;
static int g_perf_mode; // 0 off, 1 fps, 2 full
static std::atomic<int> g_perf_save; // F10 changed the mode: totk.ini is written on the present thread
static uint64_t g_perf_last_ns, g_perf_win_ns, g_perf_max_ns;
static int g_perf_frames;
static char g_perf_text[96];
static int perf_mode_of(const char *s) { return !strcmp(s, "full") ? 2 : !strcmp(s, "fps") ? 1 : 0; }
extern "C" void menu_perf_cycle(void) {
static const char *const modes[] = {"off", "fps", "full"};
cfg_set("TOTK_PERF_OVERLAY", modes[(perf_mode_of(cfg_value("TOTK_PERF_OVERLAY")) + 1) % 3]);
g_perf_save.store(1);
}
// The device the game renders on, for perfmon's GPU counters (by LUID) and NVML (by name); once, before full mode.
static void perf_gpu_once(const MenuTarget *t) {
static bool done;
if (done) return;
done = true;
VkPhysicalDeviceIDProperties id = {VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES};
VkPhysicalDeviceProperties2 p2 = {VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, &id};
vkGetPhysicalDeviceProperties2(t->phys, &p2);
VkPhysicalDeviceMemoryProperties mp;
vkGetPhysicalDeviceMemoryProperties(t->phys, &mp);
uint64_t vram = 0;
for (uint32_t h = 0; h < mp.memoryHeapCount; h++)
if (mp.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) vram += mp.memoryHeaps[h].size;
perfmon_set_gpu(p2.properties.deviceName, id.deviceLUIDValid ? id.deviceLUID : NULL, (float)(vram >> 20));
}
static void perf_tick(uint64_t now, const MenuTarget *t) {
if (cfg_version() != g_perf_ver) {
g_perf_ver = cfg_version();
int m = perf_mode_of(cfg_value("TOTK_PERF_OVERLAY"));
if (m != g_perf_mode) {
g_perf_last_ns = 0, g_perf_text[0] = 0;
if (m == 2) perf_gpu_once(t);
perfmon_mode(m);
}
g_perf_mode = m;
}
if (g_perf_mode != 1) return;
uint64_t d = now - g_perf_last_ns;
if (!g_perf_last_ns || d > 2000000000ull) { // first frame, or a stall the window should not average in
g_perf_win_ns = g_perf_max_ns = 0;
g_perf_frames = 0;
} else {
g_perf_win_ns += d;
g_perf_frames++;
if (d > g_perf_max_ns) g_perf_max_ns = d;
if (g_perf_win_ns >= 500000000ull) {
snprintf(g_perf_text, sizeof g_perf_text, menu_text(UI_PERF_LINE_FPS), g_perf_frames * 1e9 / g_perf_win_ns);
g_perf_win_ns = g_perf_max_ns = 0;
g_perf_frames = 0;
}
}
g_perf_last_ns = now;
}
// The panel keeps its width while the numbers change: every column is as wide as the widest text it can show, measured
// on samples of 8s (Roboto's digits share one width), and the numbers are right-aligned in it.
struct PerfCol { UiTextId fmt; float sample; };
static float perf_text_w(UiTextId fmt, float v) {
char b[64];
snprintf(b, sizeof b, menu_text(fmt), v);
return ImGui::CalcTextSize(b).x;
}
// A fixed column as wide as the widest of the formats (with their samples) and labels given.
static void perf_column(const char *id, const PerfCol *cols, int n, const UiTextId *labels = nullptr, int nl = 0) {
float w = ImGui::CalcTextSize(menu_text(UI_MENU_OV_NONE)).x;
for (int i = 0; i < n; i++) w = fmaxf(w, perf_text_w(cols[i].fmt, cols[i].sample));
for (int i = 0; i < nl; i++) w = fmaxf(w, ImGui::CalcTextSize(menu_text(labels[i])).x);
ImGui::TableSetupColumn(id, ImGuiTableColumnFlags_WidthFixed, w);
}
static void perf_right(const char *s, bool dim) {
float pad = ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(s).x;
if (pad > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + pad);
if (dim) ImGui::TextDisabled("%s", s);
else ImGui::TextUnformatted(s);
}
// One cell of the full panel's table: fmt (a catalog format, one number) with v, or "-" when the machine gives none.
static void perf_cell(UiTextId fmt, float v) {
ImGui::TableNextColumn();
if (v < 0) return perf_right(menu_text(UI_MENU_OV_NONE), true);
char b[64];
snprintf(b, sizeof b, menu_text(fmt), v);
perf_right(b, false);
}
static void perf_label(UiTextId label, const ImVec4 &c) {
ImGui::TableNextColumn();
ImGui::TextColored(c, "%s", menu_text(label));
}
static float perf_gb(float mb) { return mb < 0 ? -1 : mb / 1024; }
static void perf_full_draw(float w, float scale) {
PerfStats s;
perfmon_snapshot(&s);
ImGui::SetNextWindowPos(ImVec2(w - 12 * scale, 12 * scale), ImGuiCond_Always, ImVec2(1, 0));
ImGui::SetNextWindowBgAlpha(0.6f);
ImGui::Begin("##perf", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings);
ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); // over the menu while it is open
ImGui::SetWindowFontScale(0.72f);
const ImVec4 gpu_c(0.55f, 0.83f, 0.31f, 1), cpu_c(0.18f, 0.59f, 0.80f, 1), ram_c(0.80f, 0.45f, 0.70f, 1),
fps_c(0.95f, 0.95f, 0.95f, 1), wait_c(0.93f, 0.76f, 0.33f, 1);
if (ImGui::BeginTable("##perft", 4, ImGuiTableFlags_SizingFixedFit)) {
static const UiTextId labels[] = {UI_MENU_OV_GPU, UI_MENU_OV_VRAM, UI_MENU_OV_CPU, UI_MENU_OV_RAM};
static const PerfCol c0[] = {{UI_MENU_OV_FPS, 888}};
static const PerfCol c1[] = {{UI_MENU_OV_FMT_PCT, 100}, {UI_MENU_OV_FMT_GB, 888.8f}, {UI_MENU_OV_FRAME_MS, 888.8f}};
static const PerfCol c2[] = {{UI_MENU_OV_FMT_TEMP, 188}, {UI_MENU_OV_FMT_GB_OF, 888.8f}, {UI_MENU_OV_MAX_MS, 8888.8f}};
static const PerfCol c3[] = {{UI_MENU_OV_FMT_POWER, 888}, {UI_MENU_OV_FMT_MHZ, 8888}, {UI_MENU_OV_GAME_PCT, 100},
{UI_MENU_OV_GAME_GB, 888.8f}};
perf_column("##c0", c0, 1, labels, 4);
perf_column("##c1", c1, 3);
perf_column("##c2", c2, 3);
perf_column("##c3", c3, 4);
perf_label(UI_MENU_OV_GPU, gpu_c);
perf_cell(UI_MENU_OV_FMT_PCT, s.gpu_pct);
perf_cell(UI_MENU_OV_FMT_TEMP, s.gpu_temp_c);
perf_cell(UI_MENU_OV_FMT_POWER, s.gpu_power_w);
perf_label(UI_MENU_OV_VRAM, gpu_c);
perf_cell(UI_MENU_OV_FMT_GB, perf_gb(s.vram_used_mb));
perf_cell(UI_MENU_OV_FMT_GB_OF, perf_gb(s.vram_total_mb));
perf_cell(UI_MENU_OV_FMT_MHZ, s.gpu_clock_mhz);
perf_label(UI_MENU_OV_CPU, cpu_c);
perf_cell(UI_MENU_OV_FMT_PCT, s.cpu_total_pct);
perf_cell(UI_MENU_OV_FMT_TEMP, s.cpu_temp_c);
perf_cell(UI_MENU_OV_GAME_PCT, s.cpu_game_pct);
perf_label(UI_MENU_OV_RAM, ram_c);
perf_cell(UI_MENU_OV_FMT_GB, perf_gb(s.ram_used_mb));
perf_cell(UI_MENU_OV_FMT_GB_OF, perf_gb(s.ram_total_mb));
perf_cell(UI_MENU_OV_GAME_GB, perf_gb(s.ram_game_mb));
ImGui::TableNextColumn(); // the frame row: no label, the rate in its place
char b[64];
snprintf(b, sizeof b, menu_text(UI_MENU_OV_FPS), s.fps);
ImGui::TextColored(fps_c, "%s", b);
perf_cell(UI_MENU_OV_FRAME_MS, s.frame_ms_avg);
perf_cell(UI_MENU_OV_MAX_MS, s.frame_ms_max);
ImGui::EndTable();
}
float top = s.frame_ms_max > 50 ? s.frame_ms_max : 50;
ImGui::PlotLines("##ft", s.frame_ms, PERF_HISTORY, 0, nullptr, 0, top, ImVec2(330 * scale, 48 * scale));
// per frame, averaged over the last half second
if (ImGui::BeginTable("##perfw", 2, ImGuiTableFlags_SizingFixedFit)) {
static const UiTextId labels[] = {UI_MENU_OV_REPLAY, UI_MENU_OV_GPU_TIME, UI_MENU_OV_PIPE_WAIT,
UI_MENU_OV_SHADER_WAIT, UI_MENU_OV_TEX_UPLOADS};
static const PerfCol c1[] = {{UI_MENU_OV_FMT_MS, 888.88f}, {UI_MENU_OV_PER_FRAME, 888.8f}};
perf_column("##w0", nullptr, 0, labels, 5);
perf_column("##w1", c1, 2);
perf_label(UI_MENU_OV_REPLAY, wait_c);
perf_cell(UI_MENU_OV_FMT_MS, s.replay_ms);
perf_label(UI_MENU_OV_GPU_TIME, wait_c);
perf_cell(UI_MENU_OV_FMT_MS, s.gpu_ms);
perf_label(UI_MENU_OV_PIPE_WAIT, wait_c);
perf_cell(UI_MENU_OV_FMT_MS, s.pipe_wait_ms);
perf_label(UI_MENU_OV_SHADER_WAIT, wait_c);
perf_cell(UI_MENU_OV_FMT_MS, s.xlate_ms);
perf_label(UI_MENU_OV_TEX_UPLOADS, wait_c);
perf_cell(UI_MENU_OV_PER_FRAME, s.tex_uploads);
ImGui::EndTable();
}
if (s.gpu_name[0]) ImGui::TextDisabled("%s", s.gpu_name);
if (s.cpu_name[0]) ImGui::TextDisabled("%s", s.cpu_name);
ImGui::End();
}
static void perf_draw(float w, float scale) {
if (g_perf_mode == 2) return perf_full_draw(w, scale);
if (!g_perf_mode || !g_perf_text[0]) return;
ImDrawList *dl = ImGui::GetForegroundDrawList();
ImVec2 sz = ImGui::CalcTextSize(g_perf_text);
float pad = 6 * scale;
ImVec2 p(w - 12 * scale - sz.x - pad * 2, 12 * scale); // the top right corner: the game's HUD leaves it empty
dl->AddRectFilled(p, ImVec2(p.x + sz.x + pad * 2, p.y + sz.y + pad * 2), IM_COL32(0, 0, 0, 150), 4 * scale);
dl->AddText(ImVec2(p.x + pad, p.y + pad), IM_COL32(255, 255, 255, 235), g_perf_text);
}
extern "C" int menu_overlay_frame(uint64_t frame, const MenuTarget *t, const char **shot) {
*shot = NULL;
if (!g_test.parsed) test_parse();
if (g_test.next_set < g_test.n_sets && frame >= g_test.sets[g_test.next_set].frame) test_apply_sets(frame);
if (g_perf_save.exchange(0)) cfg_save();
perf_tick(plat_now_ns(), t);
bool open = g_open.load(std::memory_order_relaxed);
if (!open) {
if (g_save_pending) g_save_pending = opt_save_tick(1); // closed: the last change goes to totk.ini now
if (g_test.at && !g_test.opened && frame >= g_test.at) {
g_test.opened = frame;
g_test.page = 0;
g_tab_request = 0;
set_open(1);
open = true;
} else if (!g_perf_mode) {
return 0;
}
}
if (g_failed_flag()) {
set_open(0);
return 0;
}
uint64_t t0 = plat_now_ns();
if (!g_ctx_ok) make_context();
float scale = menu_scale(t->height);
if (!vk_up(t) || (g_font_scale != scale && !fonts_up(scale))) {
rt_log("menu: Dear ImGui Vulkan back end set-up failed; the menu stays closed");
g_failed.store(1);
set_open(0);
return 0;
}
if (g_style_scale != scale) {
ImGui::GetStyle() = g_base_style;
ImGui::GetStyle().ScaleAllSizes(scale);
g_style_scale = scale;
}
list_gpus(t->instance);
ImGuiIO &io = ImGui::GetIO();
int reset = g_reset.exchange(0);
if (reset) {
io.ClearInputKeys();
io.ClearInputMouse();
g_last_ns = 0;
g_focus_page = g_tab_request >= 0 ? g_tab_request : g_tab; // opening: the first row has the cursor
}
io.DisplaySize = ImVec2((float)t->width, (float)t->height);
double dt = g_last_ns ? (t0 - g_last_ns) / 1e9 : 1 / 60.0;
io.DeltaTime = (float)(dt < 1e-4 ? 1e-4 : dt > 0.1 ? 0.1 : dt);
g_last_ns = t0;
if (!open) { // the overlay alone: no input reaches ImGui
} else if (g_test.page < 0) {
drain_events(io);
pad_events(io, reset);
} else { // the test hook's pages: the user's input in a background test window must not change settings
plat_mutex_lock(&ev_lock);
ev_n = 0;
plat_mutex_unlock(&ev_lock);
}
ImGui_ImplVulkan_NewFrame();
ImGui::NewFrame();
if (open) layout_menu(t->width, t->height, scale);
perf_draw((float)t->width, scale);
ImGui::Render();
if (open) g_save_pending = opt_save_tick(0);
g_cost_ns += plat_now_ns() - t0;
if (g_test.page >= 0 && frame >= g_test.opened + g_test.per_page * (uint64_t)(g_test.page + 1)) {
static char path[512]; // this frame's image goes to a file; then the next tab, or the menu closes
const char *dir = cfg_get("TOTK_MENU_TEST_DIR");
snprintf(path, sizeof path, "%s/menu-test-%llu-%d.png", dir ? dir : ".", (unsigned long long)frame, g_test.page);
*shot = path;
if (++g_test.page == TAB_COUNT) {
g_test.page = -1;
set_open(0); // the next frame lays out nothing
} else {
g_tab_request = g_test.page;
}
}
return ImGui::GetDrawData()->TotalVtxCount > 0;
}
extern "C" void menu_overlay_draw(VkCommandBuffer cb) {
uint64_t t0 = plat_now_ns();
ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), cb);
g_cost_ns += plat_now_ns() - t0;
if (++g_cost_frames == 600) {
rt_log("menu: %.3f ms CPU per open frame (layout and recording, mean of 600)", g_cost_ns / 600 / 1e6);
g_cost_ns = g_cost_frames = 0;
}
}