Files
totk-pc-recomp/runtime/ui/menu.cpp
T

312 lines
13 KiB
C++

// 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.
#include <atomic>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "imgui.h"
#include "backends/imgui_impl_vulkan.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); }
// TOTK_MENU_TEST=<frame>[:<frames per page>]: the presented frame the menu opens at for the shot hook; 0 when unset
static uint64_t test_frame(void) {
static std::atomic<long long> f{-1};
long long v = f.load(std::memory_order_relaxed);
if (v < 0) {
const char *e = cfg_get("TOTK_MENU_TEST");
v = e ? atoll(e) : 0;
if (v < 0) v = 0;
f.store(v);
}
return (uint64_t)v;
}
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 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
static void make_context(uint32_t h) {
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard | ImGuiConfigFlags_NavEnableGamepad;
io.BackendPlatformName = "totk_menu";
float scale = h >= 720 ? h / 1080.0f : 720 / 1080.0f;
ImFontConfig fc;
fc.FontDataOwnedByAtlas = false;
io.Fonts->AddFontFromMemoryTTF((void *)roboto_ttf, (int)sizeof roboto_ttf, 26.0f * scale, &fc,
io.Fonts->GetGlyphRangesCyrillic());
ImGui::StyleColorsDark();
ImGuiStyle &st = ImGui::GetStyle();
st.WindowRounding = 6;
st.FrameRounding = 4;
st.WindowPadding = ImVec2(18, 14);
st.ItemSpacing = ImVec2(12, 10);
st.ScaleAllSizes(scale);
g_ctx_ok = 1;
}
static void vk_down(void) {
if (!g_vk_ok) return;
ImGui_ImplVulkan_Shutdown();
g_vk_ok = 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;
vk_queue_lock(); // the font upload submits on the queue and waits for it
int ok = ImGui_ImplVulkan_CreateFontsTexture();
vk_queue_unlock();
if (!ok) vk_down();
return ok;
}
extern "C" void menu_vk_release(void) {
if (g_ctx_ok) vk_down();
}
// ---- the page: a dimmed frame and a centred window with "Continue" and "Quit game" (Task 4 moves them into the bottom row
// of the tabbed menu). Esc on the keyboard and B on the gamepad close it.
static void layout_page(uint32_t w, uint32_t h) {
ImGui::GetBackgroundDrawList()->AddRectFilled(ImVec2(0, 0), ImVec2((float)w, (float)h), IM_COL32(0, 0, 0, 120));
ImGui::SetNextWindowPos(ImVec2(w * 0.5f, h * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(w * 0.28f, 0), ImGuiCond_Always);
ImGui::Begin("##menu", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings);
if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); // "Continue" is selected on opening
ImVec2 full(-1, 0);
if (ImGui::Button(menu_text(UI_MENU_CONTINUE), full)) set_open(0);
ImGui::Spacing();
if (ImGui::Button(menu_text(UI_MENU_QUIT), full)) gfx_window_request_close();
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_GamepadFaceRight, false)) set_open(0);
ImGui::End();
}
enum { TEST_SHOT_AFTER = 30 }; // frames between the test window opening and its shot
extern "C" int menu_overlay_frame(uint64_t frame, const MenuTarget *t, const char **shot) {
static uint64_t opened_at; // frame of the shot hook's open; ~0 once its shot is taken
*shot = NULL;
if (!g_open.load(std::memory_order_relaxed)) {
uint64_t at = test_frame();
if (!at || opened_at || frame < at) return 0;
opened_at = frame;
set_open(1);
}
if (g_failed_flag()) {
set_open(0);
return 0;
}
uint64_t t0 = plat_now_ns();
if (!g_ctx_ok) make_context(t->height);
if (!vk_up(t)) {
rt_log("menu: Dear ImGui Vulkan back end set-up failed; the menu stays closed");
g_failed.store(1);
set_open(0);
return 0;
}
ImGuiIO &io = ImGui::GetIO();
int reset = g_reset.exchange(0);
if (reset) {
io.ClearInputKeys();
io.ClearInputMouse();
g_last_ns = 0;
}
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;
drain_events(io);
pad_events(io, reset);
ImGui_ImplVulkan_NewFrame();
ImGui::NewFrame();
uint64_t since = opened_at != ~0ull && frame > opened_at ? frame - opened_at : 0;
layout_page(t->width, t->height);
ImGui::Render();
g_cost_ns += plat_now_ns() - t0;
if (opened_at != ~0ull && since >= TEST_SHOT_AFTER) { // this frame's image goes to a file, then the window closes
static char path[512];
const char *dir = cfg_get("TOTK_MENU_TEST_DIR");
snprintf(path, sizeof path, "%s/menu-test-%llu.png", dir ? dir : ".", (unsigned long long)frame);
*shot = path;
opened_at = ~0ull;
set_open(0); // the next frame lays out nothing
}
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;
}
}