Files
totk-pc-recomp/runtime/ui/menu.cpp
T
Valiantsin DzerakhandClaude Opus 5.5 b1cbbb93a6 Make the frame limit a setting: 30, 60, 120, 144 or the monitor's refresh rate
TOTK_FPS_LIMIT (fps_limit in totk.ini, live, menu row on the Game tab) replaces the 30/60 choice of TOTK_FPS60. The
pacer holds frames to the limit in nanoseconds instead of 60 Hz ticks; the real-time step has no lower bound, so game
speed stays 1.000 above 60. `display` follows the exact refresh rate of the window's monitor (QueryDisplayConfig),
measured again when the window moves or the display mode changes. A limit of 30 switches the patch set fps60 off as
before; unset, the older fps60 = 0 still means 30. Above 60 needs the real-time step and is marked experimental.

The vsync event stays at 60 Hz: on 1.4.3 the title runs at 144 fps with it, so the game does not wait for it each
frame. Field on machine A: 69-88 fps, CPU-bound (GPU 3.4 ms per frame).

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

721 lines
32 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 "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): the frame rate over the presents of the last half second, in "full"
// also their mean and longest interval, in the top right corner. 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);
}
static void perf_tick(uint64_t now) {
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;
g_perf_mode = m;
}
if (!g_perf_mode) 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) {
double fps = g_perf_frames * 1e9 / g_perf_win_ns;
if (g_perf_mode == 2)
snprintf(g_perf_text, sizeof g_perf_text, menu_text(UI_PERF_LINE_FULL), fps,
g_perf_win_ns / 1e6 / g_perf_frames, g_perf_max_ns / 1e6);
else
snprintf(g_perf_text, sizeof g_perf_text, menu_text(UI_PERF_LINE_FPS), fps);
g_perf_win_ns = g_perf_max_ns = 0;
g_perf_frames = 0;
}
}
g_perf_last_ns = now;
}
static void perf_draw(float w, float 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());
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;
}
}