From 34f89f33178bcc4997870b0b35db8617eb69a323 Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Wed, 28 Oct 2026 15:19:33 -0300 Subject: [PATCH 1/2] Save states: none in a run with no game (a disc_required game's input self-test without its disc) Since v0.3.7 every window run arms save states. A disc_required game's --input-test without its disc never starts game_main: its vsyncs run on main's stack, so the first capture point stopped the run with "state: the vsync is not on the game stack or a fiber's" (status 1), and dcb_decomp's disc-free input self-test failed. No game means nothing to hold: main no longer arms states then, and says why (port_savestate_no_game). The input self-test skips the quick slot's round trip with "no game in this run" and goes on to the menu's; the hotkeys and the debug channel give the same reason; --save-state and --load-state without the game are refused (status 64). tests/hello_test.py --menu now also builds hello as a disc_required game (examples/hello -DHELLO_DISC_REQUIRED=ON, a made-up disc no image matches): without a disc it must refuse to start, and its input self-test must pass without the game. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/GAME_CONTRACT.md | 3 ++- docs/RUNTIME.md | 4 +++- examples/hello/CMakeLists.txt | 17 ++++++++++++++- include/psxstack/savestate.h | 5 +++++ runtime/input.c | 2 +- runtime/main.c | 11 +++++++++- runtime/savestate.c | 18 +++++++++++++--- tests/hello_test.py | 40 ++++++++++++++++++++++++++++++++++- 9 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cc2bf0f..6c87ac5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ python3 tests/hello_test.py [--record] # the smoke test: examples/hel python3 tests/tasks_test.py [--sanitize|--m32] # the fibers: examples/tasks (three tasks, one preempted), its picture, its log twice, a state saved on a task's fiber and resumed python3 tests/shim_test.py [--sanitize|--m32] # the shim alone (psyq/*.c + tests/shim/shim_test.c): LIBAPI's events and root counter 3, the card's BIOS file calls and LIBCARD, CdSearchFile over a built ISO, CdRead, LIBSPU, LIBSND's SEQs python3 tests/hello_test.py --exe build/hello-win/hello.exe --wine --gpu # its Windows build (-DPSXSTACK_SDL=ON, cross-built) under Wine; --gpu: the hardware renderer's picture too (D3D12) -python3 tests/hello_test.py --menu [--gpu] # hello with the window and the in-game menu (-DPSXSTACK_MENU=ON): the input self-test offscreen, ending with the menu's round trip (unmuted live: the device opens), and the menu's pictures (Graphics; Video, Audio, System at 640x480; Mods at 1920x1080, 1280x800, 640x480), none wider than its window; --gpu: Graphics on the hardware renderer too (lavapipe), the software one but for edge pixels +python3 tests/hello_test.py --menu [--gpu] # hello with the window and the in-game menu (-DPSXSTACK_MENU=ON): the input self-test offscreen, ending with the menu's round trip (unmuted live: the device opens), and the menu's pictures (Graphics; Video, Audio, System at 640x480; Mods at 1920x1080, 1280x800, 640x480), none wider than its window; --gpu: Graphics on the hardware renderer too (lavapipe), the software one but for edge pixels; then hello as a disc_required game (-DHELLO_DISC_REQUIRED=ON): no disc refused, its input self-test without the game cmake -S launcher -B build/launcher -G Ninja [-DPSXSTACK_GAME_JSON=path/to/game.json] && cmake --build build/launcher SDL_VIDEO_DRIVER=offscreen build/launcher/-launcher --self-test build/selftest # from game.json (dw2003 by default) cmake -S launcher -B build/launcher-win -G Ninja -DCMAKE_TOOLCHAIN_FILE="$PWD/cmake/windows-x86_64.cmake" -DCMAKE_BUILD_TYPE=Release # the toolchain path must be absolute diff --git a/docs/GAME_CONTRACT.md b/docs/GAME_CONTRACT.md index 9080f06..05f8437 100644 --- a/docs/GAME_CONTRACT.md +++ b/docs/GAME_CONTRACT.md @@ -57,7 +57,8 @@ the port and the launcher stay single binaries. Contents: - **Discs:** the accepted images: label, serial, SHA-1 of the whole BIN, BIN size, region. Both the port's check and the launcher's check use this list; the error text names the label. `disc_required` (optional, default `false`): the game cannot run on reads that find no data, so the port refuses to start without a disc (a message, status 64); - `--input-test` then runs the window's input self-test on the pump alone, without the game. + `--input-test` then runs the window's input self-test on the pump alone, without the game (and without save + states: the quick slot's round trip is skipped). - **Timing:** `video.rate` (the nominal vsyncs per second: 50 PAL, 60 NTSC), the rates the launcher offers and the text that explains the alternative. - **Scenes** (optional): `video.scenes`, `{ "2d": { "label", "filter" }, "3d": { "label" } }`: the game has two present diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index c182da5..ec5cd26 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -377,7 +377,9 @@ and a later run of **the same binary** going on from it. game's fibers (docs/PORT.md "Fibers") always run on static stacks of the runtime's, so a state holds them in any case, and a load resumes the fiber the vsync ended on. Under AddressSanitizer with its fake stacks on, `--debug` alone or a window runs without states (logged; the - hotkeys say so in the log). + hotkeys say so in the log). A run with no game (a `disc_required` game's `--input-test` without its disc) has none + either: nothing to hold, so the input self-test skips the quick slot's round trip (`no game in this run`), and + `--save-state` or `--load-state` there is refused (status 64). **The state file.** A sequence of blocks, each ``, in an order fixed by the code: each module's sync function (`_state(PortState *)`, `include/psxstack/savestate.h`) diff --git a/examples/hello/CMakeLists.txt b/examples/hello/CMakeLists.txt index 428b922..4affa94 100644 --- a/examples/hello/CMakeLists.txt +++ b/examples/hello/CMakeLists.txt @@ -9,8 +9,23 @@ project(hello C) include("${CMAKE_CURRENT_SOURCE_DIR}/../../cmake/psxstack.cmake") set(HELLO_GEN "${CMAKE_BINARY_DIR}/gen/inputs") file(WRITE "${HELLO_GEN}/units.txt" "${CMAKE_CURRENT_SOURCE_DIR}/hello.c\tMAIN\n") +# -DHELLO_DISC_REQUIRED=ON (tests/hello_test.py --menu): hello described as a game that needs its disc (game.json's +# disc_required, with a made-up disc no image matches), so a run without one starts no game: the input self-test on +# the pump alone. Its Steam artwork is left out (the paths are relative to the original description). +option(HELLO_DISC_REQUIRED "hello as a disc_required game, for the no-game window path" OFF) +set(HELLO_GAME_JSON "${CMAKE_CURRENT_SOURCE_DIR}/game.json") +if(HELLO_DISC_REQUIRED) + file(READ "${HELLO_GAME_JSON}" _hello_json) + string(JSON _hello_json SET "${_hello_json}" disc_required "true") + string(JSON _hello_json SET "${_hello_json}" discs + [=[[{ "label": "no disc at all", "serial": "TEST-00000", "sha1": "0000000000000000000000000000000000000000", "size": 2352, "region": "NTSC-U" }]]=]) + string(JSON _hello_json REMOVE "${_hello_json}" launcher steam_art) + set(HELLO_GAME_JSON "${CMAKE_BINARY_DIR}/gen/game.json") + file(WRITE "${HELLO_GAME_JSON}" "${_hello_json}\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/game.json") +endif() psxstack_add_game(hello - GAME_JSON "${CMAKE_CURRENT_SOURCE_DIR}/game.json" + GAME_JSON "${HELLO_GAME_JSON}" UNITS "${HELLO_GEN}/units.txt" MAIN_UNIT "${CMAKE_CURRENT_SOURCE_DIR}/hello.c" ADAPTER "${CMAKE_CURRENT_SOURCE_DIR}/adapter.c" diff --git a/include/psxstack/savestate.h b/include/psxstack/savestate.h index 29f5c4d..23883e8 100644 --- a/include/psxstack/savestate.h +++ b/include/psxstack/savestate.h @@ -51,6 +51,11 @@ int port_savestate_add(const char *spec); void port_savestate_set_exit(void); void port_savestate_set_load(const char *path); int port_savestate_wanted(void); +/* main.c: no game runs in this run (a disc_required game's --input-test without its disc), so states stay off: the + * hotkeys, the debug channel and the input self-test say so. port_savestate_off: NULL when states are on, else why + * they are off. */ +void port_savestate_no_game(void); +const char *port_savestate_off(void); /* The game on its own stack from now on (a state option, `required`: fatal when it cannot be; or the debug channel: * logged); before port_savestate_run. */ void port_savestate_arm(int required); diff --git a/runtime/input.c b/runtime/input.c index 1f735c0..6d4e382 100644 --- a/runtime/input.c +++ b/runtime/input.c @@ -1124,7 +1124,7 @@ static int input_test_pause_before(void) { return 1; } port_log("input test: the quick slot: skipped (%s)", - !port_savestate_armed ? "save states are off in this run" : "no slot, or a hotkey unbound"); + port_savestate_off() != NULL ? port_savestate_off() : "no slot, or a hotkey unbound"); return input_test_finish(); } return 0; diff --git a/runtime/main.c b/runtime/main.c index 1b36e3a..2a78712 100644 --- a/runtime/main.c +++ b/runtime/main.c @@ -455,6 +455,11 @@ int main(int argc, char **argv) { "--config settings); only --input-test runs without one\n", PSXSTACK_GAME_TITLE); return 64; } + if (port_savestate_wanted()) { + fprintf(stderr, "port: no disc: --save-state and --load-state need the game, which does not run without " + "its disc\n"); + return 64; + } no_game = 1; } if (gpu_shots && !port_video_available()) { @@ -563,7 +568,11 @@ int main(int argc, char **argv) { } port_savestate_quick_path(quick_state != NULL ? quick_state : "quick.state"); } - if (port_savestate_wanted() || debug != NULL || window) { + if (no_game) { + /* no game, no state: the vsyncs run on main's stack, which a state cannot hold (savestate.c); the hotkeys and + * the input self-test's quick slot say so */ + port_savestate_no_game(); + } else if (port_savestate_wanted() || debug != NULL || window) { /* the game on its own stack: a state can hold it (savestate.c); a window's run for the hotkeys' quick slot */ port_savestate_arm(port_savestate_wanted()); } diff --git a/runtime/savestate.c b/runtime/savestate.c index 0af330a..0458521 100644 --- a/runtime/savestate.c +++ b/runtime/savestate.c @@ -483,6 +483,18 @@ int port_savestate_wanted(void) { return state_save_count > 0 || state_load_path != NULL; } +/* Why states are off when they are (the hotkeys', the channel's and the input self-test's message). */ +static const char *state_off_why = "save states are off in this run (see its log)"; + +void port_savestate_no_game(void) { + state_off_why = "no game in this run"; + port_log("state: off in this run (no game: nothing to hold)"); +} + +const char *port_savestate_off(void) { + return port_savestate_armed ? NULL : state_off_why; +} + /* The game on its own stack from now on (main, with a state option, `required`, or the debug channel): before * game_main. */ void port_savestate_arm(int required) { @@ -673,7 +685,7 @@ void port_savestate_resumed(void) { int port_savestate_save_now(const char *path, char *err, size_t err_size) { if (!port_savestate_armed) { - snprintf(err, err_size, "save states are off in this run (see its log)"); + snprintf(err, err_size, "%s", state_off_why); return 0; } if (state_stack_copy_n == 0 || state_stack_copy_frame != port_frames) { @@ -686,7 +698,7 @@ int port_savestate_save_now(const char *path, char *err, size_t err_size) { int port_savestate_load_prepare(const char *path, long *frame, char *err, size_t err_size) { StateHeader h; if (!port_savestate_armed) { - snprintf(err, err_size, "save states are off in this run (see its log)"); + snprintf(err, err_size, "%s", state_off_why); return 0; } if (state_load_ready) { @@ -720,7 +732,7 @@ const char *port_savestate_quick_get(void) { static int state_quick_on(const char *what) { if (!port_savestate_armed) { - port_log("state: quick %s: save states are off in this run (see its log)", what); + port_log("state: quick %s: %s", what, state_off_why); return 0; } if (state_quick_path == NULL) { diff --git a/tests/hello_test.py b/tests/hello_test.py index e4ff1f4..160457f 100644 --- a/tests/hello_test.py +++ b/tests/hello_test.py @@ -21,7 +21,8 @@ --menu builds hello with the window and the in-game menu instead and runs it on SDL's offscreen driver (menu_check); --menu --gpu also takes the menu's picture on the hardware renderer (Mesa's lavapipe when it is installed: NVIDIA's driver cannot present to the offscreen driver), which must be the software one but for a few edge pixels -(GPU_MENU_LEVELS, GPU_MENU_EDGES). +(GPU_MENU_LEVELS, GPU_MENU_EDGES). It ends with hello as a game that needs its disc (nogame_check): without one it +refuses to start, and its input self-test runs without the game (no save states: the quick slot's round trip skipped). """ import argparse import hashlib @@ -164,6 +165,7 @@ def menu_check(build, env, gpu): ok &= menu_pages(exe, cfg, env) ok &= menu_sizes(exe, cfg, env) ok &= players_check(exe, cfg, env) + ok &= nogame_check(build.parent / (build.name + "-nodisc"), env) print("hello: " + ("pass" if ok else "FAIL")) return 0 if ok else 1 @@ -380,6 +382,42 @@ def players_check(exe, cfg, env): return ok +def nogame_check(build, env): + """A game without the disc it needs (game.json disc_required): hello configured with -DHELLO_DISC_REQUIRED=ON + (examples/hello/CMakeLists.txt), with the window and the menu, into build/hello-menu-nodisc. Without --disc it + refuses to start (status 64); with --input-test it runs the self-test on the pump alone, no game started: no save + states (nothing to hold), so the quick slot's round trip is skipped and the menu's still runs; status 0.""" + if not (build / "CMakeCache.txt").exists(): + subprocess.run(["cmake", "-S", str(ROOT / "examples/hello"), "-B", str(build), "-G", "Ninja", + "-DPSXSTACK_SDL=ON", "-DPSXSTACK_MENU=ON", "-DHELLO_DISC_REQUIRED=ON"], check=True, env=env, + stdout=subprocess.DEVNULL) + subprocess.run(["cmake", "--build", str(build)], check=True, env=env, stdout=subprocess.DEVNULL) + exe = build / "hello" + run_env = dict(env, SDL_VIDEO_DRIVER="offscreen", SDL_AUDIO_DRIVER="dummy") + r = subprocess.run([str(exe), "--max-frames", "10"], capture_output=True, text=True, env=run_env, timeout=600) + good = r.returncode == 64 and "no disc" in r.stderr + print(("ok " if good else "FAIL ") + f"disc_required, no disc: status {r.returncode} (64: refused)") + if not good: + print(r.stderr.strip()) + ok = good + cfg = build / "cfg" + cfg.mkdir(exist_ok=True) + (cfg / "settings.json").write_text('{ "schema": 1 }\n') + r = subprocess.run([str(exe), "--config", str(cfg / "settings.json"), "--input-test", "--fps", "0"], + capture_output=True, text=True, env=run_env, timeout=600) + lines = r.stderr.strip().splitlines() + quick = next((l.split("input test: ", 1)[1] for l in lines if "input test: the quick slot" in l), "no quick slot line") + menu = next((l.split("input test: ", 1)[1] for l in lines if "input test: the menu:" in l), "no menu line") + good = (r.returncode == 0 and any("the input self-test runs without the game" in l for l in lines) and + quick == "the quick slot: skipped (no game in this run)" and menu.startswith("the menu: passed") and + not (cfg / "quick.state.input-test").exists()) + print(("ok " if good else "FAIL ") + f"disc_required, --input-test without the disc: status {r.returncode}; " + f"{quick}; {menu}") + if not good: + print("\n".join(lines[-15:])) + return ok and good + + def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--build", default=str(ROOT / "build/hello")) From 983f0bf32dd01a82a92ca5af53d4c034e1a073ee Mon Sep 17 00:00:00 2001 From: Gabriel Scarcella Date: Wed, 28 Oct 2026 15:19:33 -0300 Subject: [PATCH 2/2] CHANGELOG.md: v0.3.19, no save states in a run with no game Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3741a5a..2070078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Every release of psxstack, newest first. Releases are semver tags (`vMAJOR.MINOR `PSXSTACK_API` (`include/psxstack/game.h`) counts incompatible contract changes (docs/GAME_CONTRACT.md "Compatibility and versions"). Issue and pull request numbers are this repository's. +## v0.3.19 (unreleased) +- **Save states:** a run with no game (a `disc_required` game's `--input-test` without its disc, #64) no longer arms + them. Since v0.3.7 every window run did, and the first vsync, on main's stack, stopped the run (`state: the vsync is + not on the game stack or a fiber's`, status 1): dcb_decomp's disc-free input self-test failed. Now the input + self-test skips the quick slot's round trip (`no game in this run`) and goes on to the menu's; the hotkeys and the + debug channel give the same reason; `--save-state` and `--load-state` without the game are refused (status 64). +- **Tests:** `tests/hello_test.py --menu` also builds hello as a `disc_required` game (`-DHELLO_DISC_REQUIRED=ON`): + without a disc it refuses to start, and its input self-test passes without the game. + ## v0.3.18 (2026-10-26) - **Video:** separate present filters for a game's 2D and 3D scenes (dw2003recomp #122, #111, #110). A game opts in with game.json's `video.scenes` (the rows' labels and the 2D set's default filter) and the adapter's