Merge pull request #253 from mstan/feat/rewind-default-off

runtime: make local rewind opt-in, and give it a settings key (recomp-ui #38 adds on/off toggle)
This commit is contained in:
Matt Stanley
2026-08-29 07:15:33 -07:00
committed by GitHub
8 changed files with 250 additions and 6 deletions
+28
View File
@@ -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
+15
View File
@@ -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
+6
View File
@@ -2338,6 +2338,10 @@ UserSettings load_user_settings(const fs::path& path) {
s.adaptive_view = toml::find<bool>(v, "adaptive_view");
s.has_adaptive_view = true;
});
if (v.contains("rewind")) try_get([&]{
s.rewind = toml::find<bool>(v, "rewind");
s.has_rewind = true;
});
if (v.contains("rewind_depth")) try_get([&]{
int d = toml::find<int>(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)
+5
View File
@@ -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;
+129
View File
@@ -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 <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
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;
}
+11 -2
View File
@@ -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 <stdint.h>
@@ -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);
+25
View File
@@ -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);
+31 -4
View File
@@ -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;