From 77095c589c38d17bfd964d4428777d13534ec554 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Fri, 28 Aug 2026 17:33:52 -0400 Subject: [PATCH] runtime: make local rewind opt-in, and give it a settings key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewind was on for everyone and could only be turned off with PSX_REWIND=0 in the environment. It now defaults off and is enabled from settings.toml ([video] rewind) or the launcher's Display card. The cost it was charging silently: each snapshot is a whole machine — 2 MB main RAM + 1 MB VRAM + 512 KB SPU RAM — serialized uncompressed into a buffer malloc'd at a fixed 5 MiB cap that is never shrunk to the actual length, and one is captured every rewind_interval frames (15 by default, tightening toward 4 while an FMV runs), each with a 128x96 thumbnail built pixel by pixel. At the default depth of 50 a full ring is roughly 250 MB resident; at the maximum 200 it approaches 1 GB. That is a lot to hand a low-end host for a feature a session may never open. Turning it off genuinely avoids all of it: psx_rewind_configure() returns before any allocation when rewind is not wanted, and both capture paths are guarded by psx_rewind_enabled(). The only residue is a 450 KB BSS overlay panel whose pages stay untouched. The toggle applies live rather than at next launch — freeing the ring is the whole point, so waiting for a restart would miss it. psx_rewind_shutdown() already resets the overlay and drops a pending load, so tearing down mid-session is safe. PSX_REWIND still outranks the setting in both directions and now accepts 1 as well as 0. Netplay rollback is a separate subsystem with its own ring and is unaffected; rewind was already suppressed during netplay. Because off is now the common case, psx_rewind_toggle() says so on the OSD instead of leaving F8 / Select+R3 looking broken. It stays silent only in a -DPSX_REWIND=OFF build, where there is nothing to enable. Adds rewind_settings_test (default off, explicit off distinguishable from absent, save/load round trip, tuning keys independent of the enable) and the first documentation the feature has had, in config_schema.md. Verified: recompiler tests build and pass; main.cpp type-checks against the new recomp-ui ABI field; psx_rewind.c compiles both with and without PSX_HAS_RBENGINE_SNAP; the existing test_rewind_toggle_combo.py still passes. Not run end to end — this checkout has no BIOS dump to boot. Needs recomp-ui a31ecbc for Settings.rewind_enabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017AwqKPQFnK3x7xQbK4PQKj --- docs/config_schema.md | 28 +++++ recompiler/CMakeLists.txt | 15 +++ recompiler/src/config_loader.cpp | 6 + recompiler/src/config_loader.h | 5 + recompiler/tests/rewind_settings_test.cpp | 129 ++++++++++++++++++++++ runtime/include/psx_rewind.h | 13 ++- runtime/src/main.cpp | 25 +++++ runtime/src/psx_rewind.c | 35 +++++- 8 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 recompiler/tests/rewind_settings_test.cpp diff --git a/docs/config_schema.md b/docs/config_schema.md index bc848a53..90f336ea 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -404,6 +404,34 @@ Settings surface. A game migrating Skip FMVs into its built-in mod catalog sets it to false. The runtime then hides the Settings row, ignores stale persisted values, and leaves activation to the selected trusted plugin. +### Local rewind (`settings.toml`) + +Rewind is a player setting, not a game one: it lives in the user's +`settings.toml` beside the runtime executable, under `[video]`. + +```toml +[video] +rewind = false # off by default — see below +rewind_depth = 50 # snapshots kept: 50 / 100 / 150 / 200 +rewind_interval = 15 # frames between snapshots: 1 / 4 / 8 / 12 / 15 +``` + +**`rewind` defaults to `false`.** The ring holds whole *machine* snapshots — +2 MB main RAM + 1 MB VRAM + 512 KB SPU RAM, stored uncompressed — and captures +one every `rewind_interval` frames (denser, toward 4, while an FMV runs). At +the default depth that is a few hundred MB of resident memory plus a periodic +multi-megabyte copy, which is not a cost to charge every host for a feature a +session may never open. Turning it on is one click in the launcher's Display +card; nothing is allocated until it is. + +`PSX_REWIND=1` / `PSX_REWIND=0` override the setting either way, and +`PSX_REWIND_DEPTH` / `PSX_REWIND_INTERVAL` / `PSX_REWIND_FMV_INTERVAL` override +the tuning. A build configured with `-DPSX_REWIND=OFF` has no rewind at all and +ignores all of these. + +Netplay rollback is a separate subsystem with its own ring and is unaffected by +this setting; rewind is in fact suppressed while a netplay session is active. + Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a user-selected image resource behind the game image in OpenGL letterbox or diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 8527f226..4567626f 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -438,6 +438,21 @@ if(BUILD_TESTING) add_test(NAME video_enhancement_settings_test COMMAND video_enhancement_settings_test) + # [video] rewind opt-in plumbing (default off + settings round trip). + add_executable(rewind_settings_test + tests/rewind_settings_test.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp + ) + target_include_directories(rewind_settings_test PRIVATE + include + src + lib/fmt/include + lib/toml11 + ) + target_link_libraries(rewind_settings_test PRIVATE fmt) + add_test(NAME rewind_settings_test COMMAND rewind_settings_test) + # Needs bios/SCPH1001.toml — run from the framework root. add_test(NAME bios_address_model_test COMMAND bios_address_model_test diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 9e3e6ded..f0056b52 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -2338,6 +2338,10 @@ UserSettings load_user_settings(const fs::path& path) { s.adaptive_view = toml::find(v, "adaptive_view"); s.has_adaptive_view = true; }); + if (v.contains("rewind")) try_get([&]{ + s.rewind = toml::find(v, "rewind"); + s.has_rewind = true; + }); if (v.contains("rewind_depth")) try_get([&]{ int d = toml::find(v, "rewind_depth"); static const int opts[4] = {50, 100, 150, 200}; @@ -2611,6 +2615,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { f << "aspect_ratio = \"" << s.aspect_num << ":" << s.aspect_den << "\"\n"; if (s.has_adaptive_view) f << "adaptive_view = " << (s.adaptive_view ? "true" : "false") << "\n"; + if (s.has_rewind) + f << "rewind = " << (s.rewind ? "true" : "false") << "\n"; if (s.has_rewind_depth) f << "rewind_depth = " << s.rewind_depth << "\n"; if (s.has_rewind_interval) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 63c48904..cd29114a 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -1215,6 +1215,11 @@ struct UserSettings { bool has_aspect_ratio = false; int aspect_num = 4; // display aspect W:H int aspect_den = 3; // (4:3 = native) bool has_adaptive_view = false; bool adaptive_view = false; + // [video] rewind: local rewind ring on/off. OFF by default — the ring + // holds whole-machine snapshots (2 MB RAM + 1 MB VRAM + 512 KB SPU RAM + // each) and captures on a frame cadence, so it is opt-in rather than a + // cost every host pays. Env PSX_REWIND still outranks this. + bool has_rewind = false; bool rewind = false; // [video] rewind_depth: local rewind snap-ring capacity. UI offers // 50 / 100 / 150 / 200 (default 50). Runtime clamps + snaps to those steps. bool has_rewind_depth = false; int rewind_depth = 50; diff --git a/recompiler/tests/rewind_settings_test.cpp b/recompiler/tests/rewind_settings_test.cpp new file mode 100644 index 00000000..6d54ff44 --- /dev/null +++ b/recompiler/tests/rewind_settings_test.cpp @@ -0,0 +1,129 @@ +/* rewind_settings_test — [video] rewind opt-in plumbing. + * + * Local rewind keeps whole-machine snapshots (2 MB main RAM + 1 MB VRAM + + * 512 KB SPU RAM, uncompressed) in a ring and captures one every + * rewind_interval frames. At the default depth that is a few hundred MB + * resident plus a periodic multi-megabyte copy, so the feature is opt-in and + * nothing is allocated until a player asks for it. + * + * What this pins is the decision layer, not the ring itself: + * + * 1. `rewind` defaults OFF, and stays off when settings.toml says nothing; + * 2. settings.toml turns it on, and can spell an explicit off; + * 3. an absent key stays absent, so layering cannot force a value; + * 4. save_user_settings round-trips it — a launcher save that dropped the key + * would read as "the toggle does nothing"; + * 5. the tuning keys stay independent of the enable. + */ +#include "config_loader.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static int failures = 0; + +static void check(bool cond, const char* what) { + if (!cond) { + std::fprintf(stderr, "FAIL: %s\n", what); + ++failures; + } +} + +static fs::path write_temp(const std::string& name, const std::string& body) { + fs::path p = fs::temp_directory_path() / name; + std::ofstream f(p, std::ios::binary | std::ios::trunc); + f << body; + return p; +} + +/* The default is the whole point of the change: a host that has never opened + * the settings window must not be paying for the ring. */ +static void test_default_off() { + PSXRecompV4::UserSettings fresh; + check(!fresh.rewind, "rewind defaults OFF in UserSettings"); + check(!fresh.has_rewind, "a fresh UserSettings has no opinion on rewind"); + + fs::path p = write_temp("psxrecomp_rewind_absent.toml", + "[video]\n" + "supersampling = 2\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(!us.parse_error, "settings.toml parses"); + check(!us.has_rewind, "absent rewind leaves has_rewind false"); + check(!us.rewind, "absent rewind leaves the value off"); + fs::remove(p); +} + +static void test_opt_in_and_explicit_off() { + fs::path on = write_temp("psxrecomp_rewind_on.toml", + "[video]\n" + "rewind = true\n"); + auto us_on = PSXRecompV4::load_user_settings(on); + check(us_on.has_rewind && us_on.rewind, + "settings.toml rewind = true is read"); + fs::remove(on); + + /* Explicit false must be distinguishable from absent: one is a player who + * turned it off, the other is a file that never mentioned it. */ + fs::path off = write_temp("psxrecomp_rewind_off.toml", + "[video]\n" + "rewind = false\n"); + auto us_off = PSXRecompV4::load_user_settings(off); + check(us_off.has_rewind, "explicit rewind = false still sets has_rewind"); + check(!us_off.rewind, "explicit rewind = false reads as off"); + fs::remove(off); +} + +static void test_round_trip() { + PSXRecompV4::UserSettings out; + out.rewind = true; out.has_rewind = true; + out.rewind_depth = 100; out.has_rewind_depth = true; + out.rewind_interval = 4; out.has_rewind_interval = true; + + fs::path p = fs::temp_directory_path() / "psxrecomp_rewind_roundtrip.toml"; + check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes"); + + auto back = PSXRecompV4::load_user_settings(p); + check(!back.parse_error, "written settings.toml re-parses"); + check(back.has_rewind && back.rewind, + "rewind survives a save/load round trip"); + check(back.has_rewind_depth && back.rewind_depth == 100, + "rewind_depth survives a save/load round trip"); + check(back.has_rewind_interval && back.rewind_interval == 4, + "rewind_interval survives a save/load round trip"); + fs::remove(p); +} + +/* Depth/interval describe a ring that only exists once rewind is on, but they + * are still their own keys: a player may tune them while it is off and expect + * the values to be there when they turn it on. */ +static void test_tuning_independent_of_enable() { + fs::path p = write_temp("psxrecomp_rewind_tuning_only.toml", + "[video]\n" + "rewind_depth = 150\n" + "rewind_interval = 8\n"); + auto us = PSXRecompV4::load_user_settings(p); + check(!us.has_rewind, "tuning keys alone do not imply an enable"); + check(us.has_rewind_depth && us.rewind_depth == 150, + "rewind_depth is read without rewind being set"); + check(us.has_rewind_interval && us.rewind_interval == 8, + "rewind_interval is read without rewind being set"); + fs::remove(p); +} + +int main() { + test_default_off(); + test_opt_in_and_explicit_off(); + test_round_trip(); + test_tuning_independent_of_enable(); + + if (failures) { + std::fprintf(stderr, "rewind_settings_test: %d failure(s)\n", failures); + return 1; + } + std::printf("PASS: [video] rewind opt-in plumbing\n"); + return 0; +} diff --git a/runtime/include/psx_rewind.h b/runtime/include/psx_rewind.h index ad65befc..23cf98b3 100644 --- a/runtime/include/psx_rewind.h +++ b/runtime/include/psx_rewind.h @@ -10,10 +10,16 @@ * Keyboard: F8 (config.ini [KeyMap] Rewind). Controller: View/Back or L3. * While open: D-pad / left-stick Left/Right, Cross load, Circle/Back close. * - * Env: PSX_REWIND=0 disables; PSX_REWIND_INTERVAL (1/4/8/12/15, default 15); + * OFF by default. The ring holds up to 200 whole-machine snapshots (2 MB RAM + + * 1 MB VRAM + 512 KB SPU RAM each) and captures one every rewind_interval + * frames, which is not a cost to hand a low-end host for a feature a session + * may never open. Turn it on in settings.toml or the launcher's Display card. + * + * Env: PSX_REWIND=1 enables, PSX_REWIND=0 disables; either outranks the UI. + * PSX_REWIND_INTERVAL (1/4/8/12/15, default 15); * PSX_REWIND_FMV_INTERVAL (frames while depth24/MDEC/XA, default 4); * PSX_REWIND_DEPTH (50/100/150/200, default 50, max 200). - * settings.toml [video] rewind_depth / rewind_interval (env still wins). + * settings.toml [video] rewind / rewind_depth / rewind_interval (env still wins). */ #include @@ -26,6 +32,9 @@ extern "C" { /* Snap count kept in the local rewind ring. UI values: 25 / 50 / 75 / 100. */ void psx_rewind_set_depth(uint32_t depth); void psx_rewind_set_interval(uint32_t interval); +/* Settings/launcher preference. Call before psx_rewind_configure(); to change + * it mid-session, call this then configure() (on) or shutdown() (off). */ +void psx_rewind_set_enabled(int enabled); void psx_rewind_configure(uint32_t bios_checksum, uint32_t entry_pc); void psx_rewind_shutdown(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index ab4ff798..9e5ab5de 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1163,6 +1163,9 @@ static bool g_video_win_w_explicit = false; /* user chose a width */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ static int g_audio_freq = 44100; /* host device request */ static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ +/* Local rewind is opt-in: the snap ring is whole-machine state on a frame + * cadence, too much to charge every host for a feature many never open. */ +static int g_rewind_enabled = 0; static int g_rewind_depth = 50; /* local rewind snap count (50/100/150/200) */ static int g_rewind_interval = 15; /* frames between snaps (1/4/8/12/15) */ static int g_hotkey_pad_rewind = 1272; /* select + r3 */ @@ -11447,6 +11450,7 @@ int main(int argc, char** argv) { } if (us.has_audio_freq) g_audio_freq = us.audio_freq; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; + if (us.has_rewind) g_rewind_enabled = us.rewind ? 1 : 0; if (us.has_rewind_depth) g_rewind_depth = us.rewind_depth; if (us.has_rewind_interval) g_rewind_interval = us.rewind_interval; if (us.has_hotkey_pad_rewind) @@ -11903,6 +11907,7 @@ int main(int argc, char** argv) { seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true; seed.audio_freq = g_audio_freq; seed.has_audio_freq = true; seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; + seed.rewind = g_rewind_enabled != 0; seed.has_rewind = true; seed.rewind_depth = g_rewind_depth; seed.has_rewind_depth = true; seed.rewind_interval = g_rewind_interval; seed.has_rewind_interval = true; seed.hotkey_pad_rewind = g_hotkey_pad_rewind; @@ -12075,6 +12080,7 @@ int main(int argc, char** argv) { ls.frame_interp = seed.frame_interpolation ? 1 : 0; ls.frame_interp_fps = seed.frame_interpolation_fps; ls.spu_hq = seed.spu_hq ? 1 : 0; + ls.rewind_enabled = seed.rewind ? 1 : 0; ls.rewind_depth = seed.rewind_depth > 0 ? seed.rewind_depth : 50; ls.rewind_interval = seed.rewind_interval > 0 ? seed.rewind_interval : 15; ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND] = @@ -12315,6 +12321,8 @@ int main(int argc, char** argv) { seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true; seed.audio_freq = ls.audio_freq; seed.has_audio_freq = true; seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; + seed.rewind = ls.rewind_enabled != 0; + seed.has_rewind = true; seed.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; seed.has_rewind_depth = true; seed.rewind_interval = ls.rewind_interval > 0 ? ls.rewind_interval : 15; @@ -12528,6 +12536,7 @@ int main(int argc, char** argv) { g_video_aspect_den = seed.aspect_den; g_audio_freq = seed.audio_freq; g_audio_spu_hq = seed.spu_hq; + g_rewind_enabled = seed.has_rewind ? (seed.rewind ? 1 : 0) : 0; g_rewind_depth = seed.has_rewind_depth && seed.rewind_depth > 0 ? seed.rewind_depth : 50; g_rewind_interval = seed.has_rewind_interval && seed.rewind_interval > 0 @@ -13628,6 +13637,7 @@ session_reboot: savestate_configure(memcard_dir.string().c_str(), memory_get_bios_checksum(), game_entry_pc, bios_token, openbios_ws); + psx_rewind_set_enabled(g_rewind_enabled); psx_rewind_set_depth((uint32_t)g_rewind_depth); psx_rewind_set_interval((uint32_t)g_rewind_interval); psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); @@ -13979,6 +13989,7 @@ soft_return_lobby: ls.spu_hq = g_audio_spu_hq ? 1 : 0; ls.auto_skip_fmv = (skip_fmv_offered && g_auto_skip_fmv) ? 1 : 0; ls.turbo_loads = (turbo_loads_offered && g_turbo_loads_enabled) ? 1 : 0; + ls.rewind_enabled = g_rewind_enabled; ls.rewind_depth = g_rewind_depth; ls.rewind_interval = g_rewind_interval; ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND] = @@ -14239,6 +14250,8 @@ soft_return_lobby: us.has_audio_freq = true; us.spu_hq = ls.spu_hq != 0; us.has_spu_hq = true; + us.rewind = ls.rewind_enabled != 0; + us.has_rewind = true; us.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; us.has_rewind_depth = true; us.rewind_interval = ls.rewind_interval > 0 ? ls.rewind_interval : 15; @@ -14306,6 +14319,18 @@ soft_return_lobby: g_rewind_interval = ls.rewind_interval; psx_rewind_set_interval((uint32_t)g_rewind_interval); } + /* Applied live so turning rewind off frees the ring now rather + * than next launch — reclaiming it is the point of the setting. + * shutdown() also closes the overlay and drops a pending load. */ + if ((ls.rewind_enabled ? 1 : 0) != g_rewind_enabled) { + g_rewind_enabled = ls.rewind_enabled ? 1 : 0; + psx_rewind_set_enabled(g_rewind_enabled); + if (g_rewind_enabled) + psx_rewind_configure(memory_get_bios_checksum(), + game_entry_pc); + else + psx_rewind_shutdown(); + } g_hotkey_pad_rewind = normalize_hotkey_pad_binding( ls.assist_pad_bind[PSX_ASSIST_BIND_REWIND], PSX_HOTKEY_PAD_SELECT_R3); diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index b51a09cd..f0602687 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -104,6 +104,7 @@ static const uint8_t FONT8[59][8] = { void psx_rewind_set_depth(uint32_t depth) { (void)depth; } void psx_rewind_set_interval(uint32_t interval) { (void)interval; } +void psx_rewind_set_enabled(int enabled) { (void)enabled; } void psx_rewind_configure(uint32_t bios_checksum, uint32_t entry_pc) { (void)bios_checksum; @@ -154,6 +155,7 @@ static uint32_t s_fmv_interval = RW_DEF_FMV_INTERVAL; static uint32_t s_depth = RW_DEF_DEPTH; static int s_depth_pref = -1; /* -1 = unset; else from settings.toml / launcher */ static int s_interval_pref = -1; +static int s_enabled_pref = -1; /* -1 = unset; else from settings.toml / launcher */ static uint32_t s_frame; static uint32_t s_last_capture_frame = 0xffffffffu; static int s_capture_due; @@ -242,15 +244,35 @@ void psx_rewind_set_interval(uint32_t interval) s_interval = (uint32_t)s_interval_pref; } +void psx_rewind_set_enabled(int enabled) +{ + const char *e = getenv("PSX_REWIND"); + s_enabled_pref = enabled ? 1 : 0; + /* PSX_REWIND is the operator's override and outranks the UI, so once it + * has spoken the preference only records intent. Otherwise apply now; the + * caller re-arms (configure) or tears down (shutdown) to match. */ + if (e && e[0]) + return; + if (s_enabled >= 0) + s_enabled = s_enabled_pref; +} + static int rewind_wanted(void) { const char *e; if (s_enabled < 0) { + /* Off unless asked for. The ring holds up to RW_MAX_DEPTH machine + * snapshots (2 MB RAM + 1 MB VRAM + 512 KB SPU RAM each) and captures + * one every s_interval frames, which is real cost on a low-end host + * for a feature most sessions never open. Order: env wins, then the + * settings.toml / launcher preference, then off. */ e = getenv("PSX_REWIND"); - if (e && e[0] == '0') - s_enabled = 0; + if (e && e[0]) + s_enabled = (e[0] == '0') ? 0 : 1; + else if (s_enabled_pref >= 0) + s_enabled = s_enabled_pref; else - s_enabled = 1; + s_enabled = 0; { uint32_t iv_def = s_interval_pref > 0 ? (uint32_t)s_interval_pref : RW_DEF_INTERVAL; @@ -532,8 +554,13 @@ void psx_rewind_poll(CPUState *cpu, uint32_t resume_pc) int psx_rewind_toggle(void) { - if (!psx_rewind_enabled()) + if (!psx_rewind_enabled()) { + /* Rewind is off by default, so this is now the common case: say why + * rather than leaving F8 / Select+R3 looking broken. Silent only when + * the feature was compiled out, where there is nothing to turn on. */ + host_osd_push("Rewind is off — enable it in Settings", 1800); return 0; + } if (psx_netplay_active()) { host_osd_push("Rewind off during netplay", 1500); return 0;