headless: add --save-state, the counterpart to --state

--state could load a savestate but nothing could produce one without a
GUI, so savestate bugs couldn't be reproduced or regression-tested from
a script. This saves one partway through the run, once the game is
actually up.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JvJR8oJNSCimCM9KXVLjfq
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-09-04 18:14:41 -06:00
1 parent 5a84f287c4
commit 2389b8ce96
3 files changed
+21 -1

No files matched your search

+1
View File
@@ -195,6 +195,7 @@ static const CommandLineParam g_autoParams[] = {
{POFF(root), CmdParamType::String, "root", 'r', "Mount directory as the root of host0:/."},
{POFF(memStick), CmdParamType::String, "memstick", '\0', "Memory stick root directory (contains PSP/GAME etc)"},
{POFF(stateToLoad), CmdParamType::String, "state", '\0', "Load state from specified file"},
{POFF(stateToSave), CmdParamType::String, "save-state", '\0', "Save a state to FILE partway through the run", CmdLineMode::Headless},
{POFF(compare), CmdParamType::Bool, "compare", 'c', "Enable comparison mode", CmdLineMode::Headless},
{POFF(bench), CmdParamType::Bool, "bench", 'b', "Enable benchmark mode", CmdLineMode::Headless},
{POFF(oldAtrac), CmdParamType::Bool, "old-atrac", '\0', "Use old ATRAC decoder"},
+1
View File
@@ -66,6 +66,7 @@ struct CommandLineOptions {
// meant copying it in. Points at the same layout the app uses, so the two can share one.
std::optional<std::string> memStick;
std::optional<std::string> stateToLoad;
std::optional<std::string> stateToSave;
// Headless: unpack the firmware inside an official updater EBOOT.PBP (given as the boot
// filename) into this directory, then exit without booting anything.
+19 -1
View File
@@ -84,6 +84,9 @@ static bool g_writeFailureScreenshot = true;
static bool g_writeDebugOutput = true;
// Set from the savestate callback on the emu thread, read after it has been joined.
static bool g_stateLoadFailed = false;
// Set by --save-state. Saving needs the game to actually be running, so it happens from the run
// loop rather than up front like a load does.
static std::string g_stateToSave;
#if PPSSPP_PLATFORM(ANDROID)
JNIEnv *getEnv() {
@@ -338,13 +341,24 @@ static bool RunAutoTest(GraphicsContext *graphicsContext, CoreParameter &corePar
}
bool passed = true;
double deadline = time_now_d() + opt.timeout;
const double startTime = time_now_d();
double deadline = startTime + opt.timeout;
// Late enough that the game is past booting, early enough to leave the run some time after.
double saveStateAt = startTime + opt.timeout * 0.7;
coreState = coreParameter.startBreak ? CORE_STEPPING_CPU : CORE_RUNNING_CPU;
while (coreState == CORE_RUNNING_CPU || coreState == CORE_STEPPING_CPU) {
// Savestate loads/saves are queued and applied here, same as EmuScreen::render does in the
// app. Without this, --state silently did nothing at all.
SaveState::Process();
if (!g_stateToSave.empty() && time_now_d() > saveStateAt) {
const std::string filename = g_stateToSave;
g_stateToSave.clear();
SaveState::Save(Path(filename), -1, [](SaveState::Status status, std::string_view message, std::string_view) {
fprintf(stderr, "%.*s\n", (int)message.size(), message.data());
});
}
int blockTicks = (int)usToCycles(1000000 / 10);
PSP_RunLoopFor(blockTicks);
@@ -878,6 +892,10 @@ int main(int argc, const char* argv[]) {
}
}
if (cmdLineOptions.stateToSave.has_value()) {
g_stateToSave = cmdLineOptions.stateToSave.value();
}
if (stateToLoad) {
// Queued now, actually applied by SaveState::Process() once the game is up and running.
SaveState::Load(Path(stateToLoad), -1, [](SaveState::Status status, std::string_view message, std::string_view) {