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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>-launcher --self-test build/selftest # <id> 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
Expand Down
3 changes: 2 additions & 1 deletion docs/GAME_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tag length: 1 byte><tag><size: 8 bytes, host order><bytes>`, in an
order fixed by the code: each module's sync function (`<module>_state(PortState *)`, `include/psxstack/savestate.h`)
Expand Down
17 changes: 16 additions & 1 deletion examples/hello/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions include/psxstack/savestate.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion runtime/input.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion runtime/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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());
}
Expand Down
18 changes: 15 additions & 3 deletions runtime/savestate.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 39 additions & 1 deletion tests/hello_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"))
Expand Down
Loading