From 0b021491d7ff20607e62a4558b31bf8d8894aacf Mon Sep 17 00:00:00 2001 From: Filippe Faria <138038594+bronze-ff@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:58:51 -0300 Subject: [PATCH 1/4] feat: add a Quit option to the gameplay menu Assisted-by: OpenAI Codex Co-Authored-By: OpenAI Codex --- gpu/vk/vk_menu.cpp | 25 +++++++++++++++++++++---- host/posix/display_sdl.c | 18 ++++++++++++------ host/posix/windows.h | 1 + 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index d4f8ba76..bb2c17a2 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -256,7 +256,7 @@ Row rows[] = { "Bounds-checked buffer reads: a read past a buffer's end is dropped instead of stopping the GPU. Auto is on, on every card; " "Off can be faster on some NVIDIA cards. Applies after restart."}, - // Gameplay (3 rows) + // Gameplay {TAB_GAMEPLAY, "FPS counter", &opts.show_fps, 0, 1, 1, tx_onoff, "Shows frames per second in the top right corner. With frame generation on, it also shows the game's own rate.", true}, {TAB_GAMEPLAY, "Game speed", &opts.realtime, 0, 1, 1, tx_speed, @@ -302,10 +302,14 @@ Row rows[] = { "Nintendo (default Switch order), Xbox / PC (A for action/talk), or Swap Face. Individual bindings switch this to Custom."}, {TAB_CONTROLS, "Controls", nullptr, 0, 0, 0, nullptr, // (hookshot: opens its page) "Which gamepad button and which keys press each of the Switch's buttons."}, + {TAB_GAMEPLAY, "Quit", nullptr, 0, 0, 0, nullptr, + "Save your progress first. Press Enter or the bottom face button twice to quit. Esc or the right face button cancels."}, {TAB_GAMEPLAY, "Cheats", nullptr, 0, 0, 0, nullptr, "Open the private gameplay tools."}, }; constexpr int kCheatsRow = sizeof rows / sizeof rows[0] - 1; +constexpr int kQuitRow = kCheatsRow - 1; +bool quit_pending = false; bool cheats_enabled = false; std::atomic protect_hearts{-1}; // -1 disables the shortcut, 0 enables it on the ground, and 1 means flying. @@ -1031,7 +1035,8 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { SIZE nsz{}; GetTextExtentPoint32A(dc, r.name, (int)strlen(r.name), &nsz); const int room = (int)w - 18 * s - (18 * s + (int)nsz.cx + 12 * s); - const std::string label = r.value ? r.text(*r.value) : ">"; // (">": a page) + const char* action = quit_pending && &r == &rows[kQuitRow] ? "Confirm" : ">"; + const std::string label = r.value ? r.text(*r.value) : action; const bool arrows = r.value && sel; auto width = [&](const std::string& t) { SIZE z{}; @@ -1763,7 +1768,10 @@ void menu_tick() { } } } - if (press[5]) open_now = false; + if (press[5]) { + if (quit_pending) quit_pending = false, changed = true; + else open_now = false; + } // (the tabs: LB and RB, Q and E, or Tab) if (tab_left) cur_tab = (cur_tab + TAB_COUNT - 1) % TAB_COUNT, changed = true; else if (tab_right) cur_tab = (cur_tab + 1) % TAB_COUNT, changed = true; @@ -1772,7 +1780,15 @@ void menu_tick() { if (press[1]) selected = (selected + tab_count - 1) % tab_count, changed = true; if (press[2]) selected = (selected + 1) % tab_count, changed = true; Row& r = rows[tab_row_to_global(cur_tab, selected)]; - if (press[7] && &r == &rows[kCheatsRow]) page = 2, changed = true; + const bool quit_selected = &r == &rows[kQuitRow]; + if (quit_pending && (!quit_selected || !mine)) quit_pending = false, changed = true; + if (press[7] && quit_selected) { + if (mine && !press[5]) { + if (quit_pending) { + if (const HWND window = app_window()) PostMessageA(window, WM_CLOSE, 0, 0); + } else quit_pending = true, changed = true; + } + } else if (press[7] && &r == &rows[kCheatsRow]) page = 2, changed = true; else if (press[7] && !r.value && can_bind()) page = 1, csel = 0, ccol = 0, changed = true; if ((press[3] || press[4] || press[7]) && r.value) { // (Enter, or the bottom face button, as Right) if (press[7]) press[4] = true; @@ -1796,6 +1812,7 @@ void menu_tick() { if (!now_open) { if (drag_row >= 0) save(); // (a slider let go off the window: kept now) hover_tab = -1, drag_row = -1, mouse_read = false; + quit_pending = false; } if (now_open != told && app_window()) { told = now_open; diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index 76c89ad6..f8af7d5c 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -145,13 +145,21 @@ void hos_renderer_wait(void) { static volatile int start_asked; static volatile int fullscreen_asked = -1; /* (the Insert menu's row, from the renderer's thread: applied on this one) */ static volatile int menu_asked = -1; /* (the Insert menu opened or closed, likewise) */ +static volatile int close_asked; -/* PostMessageA to the window (host/posix/win32_posix.c): WM_APP + 7, the Insert menu's Fullscreen row, WM_APP + 8 the - menu opened or closed (host/display.c's) */ +/* PostMessageA to the window (host/posix/win32_posix.c), handled on the SDL thread. */ __attribute__((visibility("default"))) void hos_posix_post_message(uint32_t msg, uintptr_t wp, intptr_t lp) { (void)lp; if (msg == WM_APP + 7) __atomic_store_n(&fullscreen_asked, wp ? 1 : 0, __ATOMIC_RELEASE); if (msg == WM_APP + 8) __atomic_store_n(&menu_asked, wp ? 1 : 0, __ATOMIC_RELEASE); + if (msg == WM_CLOSE) __atomic_store_n(&close_asked, 1, __ATOMIC_RELEASE); +} + +static _Noreturn void close_window(void) { + rx_close_asked(); /* (a start still preparing the shaders keeps what it made) */ + hos_log("display: window closed, exiting"); + hos_log_flush(); + ExitProcess(0); } /* the menu opened: the mouse let go (its capture kept for later); closed: the capture back, if the window still has the focus */ static int captured_before_menu; @@ -240,16 +248,14 @@ void hos_display_loop(void) { if (getenv("HOS_FULLSCREEN") && atoi(getenv("HOS_FULLSCREEN"))) set_fullscreen(1); CloseHandle(CreateThread(NULL, 0, renderer_main, NULL, 0, NULL)); for (;;) { + if (__atomic_load_n(&close_asked, __ATOMIC_ACQUIRE)) close_window(); hos_sdl_pads_pump(); if (hos_sdl_wait_event(&e, 4)) { do { switch (e.type) { case HOS_SDL_EV_QUIT: case HOS_SDL_EV_CLOSE: - rx_close_asked(); /* (a start still preparing the shaders keeps what it made) */ - hos_log("display: window closed, exiting"); - hos_log_flush(); - ExitProcess(0); + close_window(); case HOS_SDL_EV_RESIZED: if (renderer) { int w = 0, h = 0; diff --git a/host/posix/windows.h b/host/posix/windows.h index 85d262f0..f4c4b00c 100644 --- a/host/posix/windows.h +++ b/host/posix/windows.h @@ -537,6 +537,7 @@ HWND GetForegroundWindow(void); #define WS_EX_NOACTIVATE 0x08000000 static inline LONG_PTR GetWindowLongPtrA(HWND w, int i) { (void)w, (void)i; return 0; } /* (the Insert menu's Fullscreen row, gpu/vk/vk_menu.cpp: WM_APP + 7 to the window sets it, GWL_STYLE without WS_CAPTION reads it) */ +#define WM_CLOSE 0x0010 #define WM_APP 0x8000 #define WS_CAPTION 0x00C00000L BOOL PostMessageA(HWND w, UINT msg, WPARAM wp, LPARAM lp); From a7b27decc7ed701281b0f6971988bb8cddd8628d Mon Sep 17 00:00:00 2001 From: Filippe Faria <138038594+bronze-ff@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:43:23 -0300 Subject: [PATCH 2/4] menu: Quit saves first, as Restart now does; note the rows' order Review of #1255: the window's own close (rx_close_asked, then hos_window_closed_exit) does not force the pipeline cache, so Quit now calls save() and pipeline_cache_save(true) before posting WM_CLOSE, as the restart question's Restart now does. A comment by the table says Quit and Cheats stay its last two rows, since kQuitRow and kCheatsRow count back from its end (rows[] is not constexpr, so a static_assert on the name cannot check it). Co-Authored-By: Claude Opus 5.5 --- gpu/vk/vk_menu.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index d81e0fee..6459ffdf 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -517,6 +517,7 @@ Row rows[] = { "Which face button is which. Binding one yourself on the Controls page makes it Custom.", hn_pad_layout}, {TAB_CONTROLS, "Controls", nullptr, 0, 0, 0, nullptr, // (hookshot: opens its page) "Which gamepad button and which keys press each of the Switch's buttons."}, + // (Quit and Cheats stay the table's last two rows: kQuitRow and kCheatsRow count back from its end) {TAB_GAMEPLAY, "Quit", nullptr, 0, 0, 0, nullptr, "Save your progress first. Press Enter or the bottom face button twice to quit. Esc or the right face button cancels."}, {TAB_GAMEPLAY, "Cheats", nullptr, 0, 0, 0, nullptr, @@ -2184,7 +2185,9 @@ void menu_tick() { if (quit_pending && (!quit_selected || !mine)) quit_pending = false, changed = true; if (press[7] && quit_selected) { if (mine && !press[5]) { - if (quit_pending) { + if (quit_pending) { // (as Restart now: saved first, as the window's own close does not force the pipeline cache) + save(); + pipeline_cache_save(true); if (const HWND window = app_window()) PostMessageA(window, WM_CLOSE, 0, 0); } else quit_pending = true, changed = true; } From 224d74454c0cafd95643d8aed528ecbd61696529 Mon Sep 17 00:00:00 2001 From: Filippe Faria <138038594+bronze-ff@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:52:36 -0300 Subject: [PATCH 3/4] menu: a Restart row in Gameplay, beside Quit Restart starts the game again from the Insert menu, with the same confirm step as Quit: the first press shows Confirm, a second one restarts, B or Esc cancels. It saves first and posts WM_APP + 9, the restart question's Restart now path (host/hos_relaunch.c: execve in place on Linux, a new process on Windows). quit_pending becomes pending, the row waiting for its second press, so the two rows share the confirm state. Co-Authored-By: Claude Opus 5.5 --- gpu/vk/vk_menu.cpp | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 6459ffdf..ab81729c 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -517,7 +517,10 @@ Row rows[] = { "Which face button is which. Binding one yourself on the Controls page makes it Custom.", hn_pad_layout}, {TAB_CONTROLS, "Controls", nullptr, 0, 0, 0, nullptr, // (hookshot: opens its page) "Which gamepad button and which keys press each of the Switch's buttons."}, - // (Quit and Cheats stay the table's last two rows: kQuitRow and kCheatsRow count back from its end) + // (Restart, Quit and Cheats stay the table's last three rows: kRestartRow, kQuitRow and kCheatsRow count back from its end) + {TAB_GAMEPLAY, "Restart", nullptr, 0, 0, 0, nullptr, + "Starts the game again. Save your progress first. Press Enter or the bottom face button twice to restart. Esc or the " + "right face button cancels."}, {TAB_GAMEPLAY, "Quit", nullptr, 0, 0, 0, nullptr, "Save your progress first. Press Enter or the bottom face button twice to quit. Esc or the right face button cancels."}, {TAB_GAMEPLAY, "Cheats", nullptr, 0, 0, 0, nullptr, @@ -525,7 +528,8 @@ Row rows[] = { }; constexpr int kCheatsRow = sizeof rows / sizeof rows[0] - 1; constexpr int kQuitRow = kCheatsRow - 1; -bool quit_pending = false; +constexpr int kRestartRow = kCheatsRow - 2; +const Row* pending = nullptr; // (Restart or Quit: shows Confirm until its second press) bool cheats_enabled = false; std::atomic protect_hearts{-1}; // -1 disables the shortcut, 0 enables it on the ground, and 1 means flying. @@ -1359,7 +1363,7 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { SIZE nsz{}; GetTextExtentPoint32A(dc, r.name, (int)strlen(r.name), &nsz); const int room = (int)w - 18 * s - (18 * s + (int)nsz.cx + 12 * s); - const char* action = quit_pending && &r == &rows[kQuitRow] ? "Confirm" : ">"; // (">": a page) + const char* action = pending == &r ? "Confirm" : ">"; // (">": a page) std::string label = r.value ? r.text(*r.value) : action; if (r.value && r.restart && *r.value != restart_state.start[restart_slot_of(r)]) label += " (after restart)"; const bool arrows = r.value && sel && !row_fixed(r.value); @@ -2170,7 +2174,7 @@ void menu_tick() { } } } - if (press[5] && quit_pending) quit_pending = false, changed = true; // (a Quit waiting for its second press: cancelled) + if (press[5] && pending) pending = nullptr, changed = true; // (a Restart or Quit waiting for its second press: cancelled) else if (press[5] && restart_ask()) page = 5, ask_sel = 0, changed = true; // (the restart question first) else if (press[5]) open_now = false; // (the tabs: LB and RB, Q and E, or Tab) @@ -2181,15 +2185,24 @@ void menu_tick() { if (press[1]) selected = (selected + tab_count - 1) % tab_count, changed = true; if (press[2]) selected = (selected + 1) % tab_count, changed = true; Row& r = rows[tab_row_to_global(cur_tab, selected)]; - const bool quit_selected = &r == &rows[kQuitRow]; - if (quit_pending && (!quit_selected || !mine)) quit_pending = false, changed = true; - if (press[7] && quit_selected) { + const bool confirm_selected = &r == &rows[kQuitRow] || &r == &rows[kRestartRow]; + if (pending && (pending != &r || !mine)) pending = nullptr, changed = true; + if (press[7] && confirm_selected) { if (mine && !press[5]) { - if (quit_pending) { // (as Restart now: saved first, as the window's own close does not force the pipeline cache) + if (pending != &r) { + pending = &r, changed = true; + } else { // (as Restart now: saved first, as the window's own close does not force the pipeline cache) save(); pipeline_cache_save(true); - if (const HWND window = app_window()) PostMessageA(window, WM_CLOSE, 0, 0); - } else quit_pending = true, changed = true; + pending = nullptr; + if (&r == &rows[kRestartRow]) { // (the restart question's Restart now: the window closes, then the game starts again) + logf("menu: restart"); + open_now = false, changed = true; + PostMessageA(app_window(), WM_APP + 9, 0, 0); + } else if (const HWND window = app_window()) { + PostMessageA(window, WM_CLOSE, 0, 0); + } + } } } else if (press[7] && &r == &rows[kCheatsRow]) page = 2, changed = true; else if (press[7] && !r.value && can_bind()) page = 1, csel = 0, ccol = 0, changed = true; @@ -2215,7 +2228,7 @@ void menu_tick() { if (!now_open) { if (drag_row >= 0) save(); // (a slider let go off the window: kept now) hover_tab = -1, drag_row = -1, mouse_read = false; - quit_pending = false; + pending = nullptr; } if (now_open != told && app_window()) { told = now_open; From ff0651ac0fa274e3870a8501f08203bdc9e07a5e Mon Sep 17 00:00:00 2001 From: Filippe Faria <138038594+bronze-ff@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:11:47 -0300 Subject: [PATCH 4/4] menu: Restart checks the window before posting, as Quit does Review of #1266: Quit posts WM_CLOSE only when app_window() has a window; Restart now does the same before WM_APP + 9. Co-Authored-By: Claude Opus 5.5 --- gpu/vk/vk_menu.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index ab81729c..bf57fdce 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -2198,7 +2198,7 @@ void menu_tick() { if (&r == &rows[kRestartRow]) { // (the restart question's Restart now: the window closes, then the game starts again) logf("menu: restart"); open_now = false, changed = true; - PostMessageA(app_window(), WM_APP + 9, 0, 0); + if (const HWND window = app_window()) PostMessageA(window, WM_APP + 9, 0, 0); } else if (const HWND window = app_window()) { PostMessageA(window, WM_CLOSE, 0, 0); }