Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions runner/src/desktop/host_api.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions runner/src/desktop/host_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions runner/tests/test_host_exit_api.py
Original file line number Diff line number Diff line change
@@ -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 <stdarg.h>
#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)
Loading