From 7662bb9dfd1250ae28b0d311210bdd3115b894e5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 07:27:45 -0700 Subject: [PATCH] Add replay-safe host UI actions and graceful application exit --- runner/src/desktop/host_api.h | 11 ++++ runner/src/desktop/host_main.c | 17 ++++++ runner/tests/test_host_exit_api.py | 85 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 runner/tests/test_host_exit_api.py diff --git a/runner/src/desktop/host_api.h b/runner/src/desktop/host_api.h index 653edfbc..263d8790 100644 --- a/runner/src/desktop/host_api.h +++ b/runner/src/desktop/host_api.h @@ -29,6 +29,17 @@ extern SDL_Window *g_window; * pulling in this one. */ extern bool g_config_dirty; +/* Game-owned host UI may change persistent presentation settings only while + * this is true. Speculative frames and netplay rollback replay must not + * change a window, write settings or perform application actions. */ +bool snesrecomp_host_frame_is_live(void); + +/* Request a normal application shutdown at the host's next event poll. + * Returns to the caller; configuration, SRAM and host resources follow the + * same shutdown path as closing the window. Call after SDL initialization. + * Speculative frames and netplay rollback replays cannot request an exit. */ +void snesrecomp_request_exit(void); + /* Enables or disables fullscreen/borderless mode from dependent project code. * Clamps mode to {0,1}: 0 = windowed, 1 = borderless * (SNESRECOMP_SDL_WINDOW_FULLSCREEN_DESKTOP). diff --git a/runner/src/desktop/host_main.c b/runner/src/desktop/host_main.c index 63e9e3a0..997154c6 100644 --- a/runner/src/desktop/host_main.c +++ b/runner/src/desktop/host_main.c @@ -418,6 +418,20 @@ static int g_savestate_menu_hotkey; static int g_rewind_hotkey; static int g_open_launcher_hotkey; /* in_game_launcher only */ static uint64_t g_state_generation; +static bool g_host_replaying_frame; + +bool snesrecomp_host_frame_is_live(void) { + return !RtlSpeculativeFrame() && !g_host_replaying_frame; +} + +void snesrecomp_request_exit(void) { + if (!snesrecomp_host_frame_is_live()) return; + SDL_Event event; + memset(&event, 0, sizeof(event)); + event.type = SDL_QUIT; + if (SDL_PushEvent(&event) <= 0) + host_report_breadcrumb("host exit request could not be queued: %s", SDL_GetError()); +} /* The last field actually presented, kept so an overlay can freeze the guest * and still have something to draw behind itself. Sized like g_my_pixels. */ @@ -1270,9 +1284,12 @@ static void CaptureSimulationFrame(unsigned number) { #if defined(SNESRECOMP_NET_ROLLBACK) static void NetplayReplayFrame(uint32_t inputs, uint32_t tick) { + bool was_replaying = g_host_replaying_frame; + g_host_replaying_frame = true; if (g_game->before_run_frame) g_game->before_run_frame(); RtlRunFrame(inputs); CaptureSimulationFrame(tick + 1); + g_host_replaying_frame = was_replaying; } #endif diff --git a/runner/tests/test_host_exit_api.py b/runner/tests/test_host_exit_api.py new file mode 100644 index 00000000..c3b0e1a8 --- /dev/null +++ b/runner/tests/test_host_exit_api.py @@ -0,0 +1,85 @@ +"""Exercise the real host exit API against SDL's event queue. + +ELF section collection lets this small fixture include the actual desktop +implementation without linking an entire title. Windows coverage comes from +the built-title shutdown check; PE retains unrelated host references here. +""" +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.skipif(sys.platform != "linux", reason="ELF host fixture") +@pytest.mark.parametrize("sdl", ["sdl2", "sdl3"]) +def test_exit_uses_normal_events_and_ignores_replay(tmp_path, sdl): + cc = shutil.which("cc") + pkg = shutil.which("pkg-config") + if not cc or not pkg: + pytest.skip("C compiler and pkg-config required") + available = subprocess.run([pkg, "--exists", sdl], check=False) + if available.returncode: + pytest.skip(f"{sdl} development package required") + source = tmp_path / "exit_test.c" + source.write_text(r''' +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include "desktop/host_main.c" +static bool test_speculative; +bool RtlSpeculativeFrame(void) { return test_speculative; } +void host_report_breadcrumb(const char *fmt, ...) { (void)fmt; } +int main(void) { +#if SNESRECOMP_SDL3 + assert(SDL_Init(SDL_INIT_EVENTS)); +#else + assert(SDL_Init(SDL_INIT_EVENTS) == 0); +#endif + SDL_Event event; + while (SDL_PollEvent(&event)) {} + assert(snesrecomp_host_frame_is_live()); + test_speculative = true; + assert(!snesrecomp_host_frame_is_live()); + snesrecomp_request_exit(); + assert(!SDL_PollEvent(&event)); + test_speculative = false; + g_host_replaying_frame = true; + assert(!snesrecomp_host_frame_is_live()); + snesrecomp_request_exit(); + assert(!SDL_PollEvent(&event)); + g_host_replaying_frame = false; + assert(snesrecomp_host_frame_is_live()); + snesrecomp_request_exit(); + assert(SDL_PollEvent(&event)); + assert(event.type == SDL_QUIT); + assert(!SDL_PollEvent(&event)); + SDL_Quit(); + return 0; +} +''', encoding="utf-8") + flags = shlex.split(subprocess.check_output( + [pkg, "--cflags", "--libs", sdl], text=True)) + executable = tmp_path / "exit_test" + subprocess.run([ + cc, "-std=gnu11", "-O2", "-ffunction-sections", "-fdata-sections", + f"-DSNESRECOMP_SDL3={int(sdl == 'sdl3')}", + f"-DLNG_SDL3={int(sdl == 'sdl3')}", "-DSDL_MAIN_HANDLED", + "-I" + str(ROOT / "runner/src"), + "-I" + str(ROOT / "runner/src/snes"), + "-I" + str(ROOT / "runner/src/desktop"), + "-I" + str(ROOT / "third_party"), + "-I" + str(ROOT / "third_party/gl_core"), + str(source), "-Wl,--gc-sections", *flags, "-lm", "-o", str(executable), + ], check=True, capture_output=True, text=True, timeout=60) + env = os.environ.copy() + env["LD_LIBRARY_PATH"] = subprocess.check_output( + [pkg, "--variable=libdir", sdl], text=True).strip() + subprocess.run([str(executable)], env=env, check=True, timeout=15)