Headless: Boot a savestate without its game

Running a .ppst boots a placeholder program, with the disc ID and boot type peeked from the
state, then loads the state over it. An ISO game gets a disc with nothing on it, which the
mounts now load into by name. Meant for measuring performance with states that can be kept
without the games; the app still refuses to boot a state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-11-01 11:04:26 +01:00
1 parent 09f65a860c
commit 8122a5ec70
8 files changed
+127 -1

No files matched your search

+2
View File
@@ -92,6 +92,8 @@ struct CoreParameter {
bool startBreak = false;
std::string *collectDebugOutput = nullptr;
bool headLess = false; // Try to avoid messageboxes etc
// Headless only: allows booting a savestate without its game, for measuring performance.
bool bootFromState = false;
// Headless leaves out compat settings when replaying frame dumps, unless this is set.
bool dumpCompat = false;
// Headless: GPU_USE_ feature bits to turn off, to test the fallbacks for GPUs without them.
+23
View File
@@ -2618,6 +2618,29 @@ bool __KernelLoadGEDump(MIPSState *mips, std::string_view base_filename, std::st
return true;
}
void __KernelLoadStateBootStub(MIPSState *mips) {
__KernelLoadReset();
constexpr u32 codeStartAddr = PSP_GetUserMemoryBase();
mips->pc = codeStartAddr;
// b . (to itself), and the delay slot.
Memory::WriteUnchecked_U32(MIPS_MAKE_B(-1), codeStartAddr);
Memory::WriteUnchecked_U32(MIPS_MAKE_NOP(), codeStartAddr + 4);
PSPModule *module = new PSPModule();
kernelObjects.Create(module);
loadedModules.insert(module->GetUID());
memset(&module->nm, 0, sizeof(module->nm));
module->isFake = true;
module->nm.entry_addr = codeStartAddr;
module->nm.gp_value = -1;
SceUID threadID = __KernelSetupRootThread(module->GetUID(), 0, nullptr, 0x20, 0x1000, 0);
__KernelSetThreadRA(threadID, NID_MODULERETURN);
__KernelStartIdleThreads(module->GetUID());
}
int __KernelGPUReplay(MIPSState *mips) {
// Special ABI: s0 and s1 are the "args". Not null terminated.
const char *filenamep = Memory::GetCharPointer(mips->r[MIPS_REG_S1]);
+2
View File
@@ -241,6 +241,8 @@ class MIPSState;
u32 __KernelGetModuleGP(SceUID module);
bool KernelModuleIsKernelMode(SceUID module);
bool __KernelLoadGEDump(MIPSState *mips, std::string_view base_filename, std::string *error_string);
// A placeholder program that loops until a savestate is loaded over it.
void __KernelLoadStateBootStub(MIPSState *mips);
// hostArgs, if set, are passed to the module instead of what paramPtr points to.
bool __KernelLoadExec(MIPSState *mips, const char *filename, u32 paramPtr, std::string *error_string, const std::vector<u8> *hostArgs = nullptr);
// The VSH (the XMB) is what's running, not a game it launched.
+60
View File
@@ -54,6 +54,7 @@
#include "Core/ConfigValues.h"
#include "Core/System.h"
#include "Core/PSPLoaders.h"
#include "Core/SaveState.h"
#include "Core/HLE/sceKernelModule.h"
#include "Core/HLE/PopsBoot.h"
#include "Core/Util/PSARUnpack.h"
@@ -699,6 +700,65 @@ bool Load_PSP_POPS(FileLoader *fileLoader, std::string *error_string) {
return true;
}
// A disc with nothing on it. Every block reads as zeros, so the ISO filesystem finds no volume
// descriptor and no files.
class EmptyDiscBlockDevice : public BlockDevice {
public:
EmptyDiscBlockDevice() : BlockDevice(nullptr) {}
bool ReadBlock(int blockNumber, u8 *outPtr, bool uncached) override {
memset(outPtr, 0, GetBlockSize());
return true;
}
u32 GetNumBlocks() const override { return 0; }
bool IsDisc() const override { return true; }
};
bool MountStateBoot(const Path &statePath, std::string *errorString) {
SaveState::BootInfo info;
if (!SaveState::PeekBootInfo(statePath, &info, errorString)) {
return false;
}
if (info.discID.empty()) {
*errorString = "The state doesn't say which game it's from";
return false;
}
switch (info.bootType) {
case SaveState::BootType::ISO:
{
// The game's open files on the disc are dropped as the state loads, and opening any
// file fails, so the game only gets so far.
auto iso = std::make_shared<ISOFileSystem>(&pspFileSystem, std::make_shared<EmptyDiscBlockDevice>());
auto blockSystem = std::make_shared<ISOBlockSystem>(iso);
pspFileSystem.Mount("umd0:", blockSystem);
pspFileSystem.Mount("umd1:", blockSystem);
pspFileSystem.Mount("umd:", blockSystem);
pspFileSystem.Mount("disc0:", iso);
break;
}
case SaveState::BootType::PBP:
case SaveState::BootType::ELF:
break;
default:
*errorString = "Can only boot states of games run from an ISO, PBP or ELF without the game";
return false;
}
g_paramSFO.SetValue("DISC_ID", info.discID, 16);
g_paramSFO.SetValue("DISC_VERSION", info.discVersion, 8);
g_paramSFO.SetValue("TITLE", SaveState::GetTitle(statePath), 128);
INFO_LOG(Log::Loader, "Booting state %s of %s %s without the game", statePath.c_str(), info.discID.c_str(), info.discVersion.c_str());
return true;
}
bool Load_PSP_StateBoot(std::string *error_string) {
if (PSP_CoreParameter().loadGameConfigs) {
g_Config.LoadGameConfig(g_paramSFO.GetDiscID());
System_PostUIMessage(UIMessage::CONFIG_LOADED);
}
__KernelLoadStateBootStub(currentMIPS);
return true;
}
bool Load_PSP_GE_Dump(FileLoader *fileLoader, std::string *error_string) {
auto umd = std::make_shared<BlobFileSystem>(&pspFileSystem, fileLoader, "data.ppdmp");
pspFileSystem.Mount("disc0:", umd);
+4
View File
@@ -26,6 +26,10 @@ class BlockDevice;
bool Load_PSP_ISO(FileLoader *fileLoader, std::string *error_string);
bool Load_PSP_ELF_PBP(FileLoader *fileLoader, std::string_view discId, bool loadGameConfigs, std::string *error_string);
bool Load_PSP_GE_Dump(FileLoader *fileLoader, std::string *error_string);
// Booting a savestate without its game: the first sets up the disc ID and a disc with nothing on it,
// the second a placeholder program that runs until the state is loaded.
bool MountStateBoot(const Path &statePath, std::string *errorString);
bool Load_PSP_StateBoot(std::string *error_string);
bool Load_PSP_POPS(FileLoader *fileLoader, std::string *error_string);
bool MountGameISO(FileLoader *fileLoader, std::string *errorString);
+16 -1
View File
@@ -297,7 +297,7 @@ static void GetBootError(IdentifiedFileType type, std::string *errorString) {
case IdentifiedFileType::PS2_ISO: *errorString = "PS2 game image detected."; break;
case IdentifiedFileType::PS3_ISO: *errorString = "PS2 game image detected."; break;
case IdentifiedFileType::NORMAL_DIRECTORY: *errorString = "Just a directory."; break;
case IdentifiedFileType::PPSSPP_SAVESTATE: *errorString = "This is a saved state, not a game."; break; // Actually, we could make it load it...
case IdentifiedFileType::PPSSPP_SAVESTATE: *errorString = "This is a saved state, not a game."; break;
case IdentifiedFileType::PSP_SAVEDATA_DIRECTORY: *errorString = "This is save data, not a game."; break;
case IdentifiedFileType::PSP_PS1_PBP: *errorString = "PS1 EBOOTs are not supported by PPSSPP."; break;
case IdentifiedFileType::PSP_UMD_VIDEO_ISO: *errorString = std::string("UMD Video plays in the XMB, which needs PSP firmware ") + UMD_VIDEO_MIN_FIRMWARE + " or later."; break;
@@ -481,6 +481,15 @@ static bool CPU_Init(FileLoader *fileLoader, IdentifiedFileType type, std::strin
// Title and disc ID; the PS1 image itself is read by the firmware's POPS manager.
LoadParamSFOFromPBP(fileLoader);
break;
case IdentifiedFileType::PPSSPP_SAVESTATE:
if (!g_CoreParameter.bootFromState) {
GetBootError(type, errorString);
return false;
}
if (!MountStateBoot(g_CoreParameter.fileToStart, errorString)) {
return false;
}
break;
case IdentifiedFileType::PSP_UMD_VIDEO_ISO:
{
// The game list boots the XMB for these, which plays them.
@@ -669,6 +678,12 @@ static bool CPU_Init(FileLoader *fileLoader, IdentifiedFileType type, std::strin
return false;
}
break;
case IdentifiedFileType::PPSSPP_SAVESTATE:
// Headless loads the state itself, as with --state.
if (!Load_PSP_StateBoot(errorString)) {
return false;
}
break;
default:
GetBootError(type, errorString);
+8
View File
@@ -223,6 +223,14 @@ To check that a game keeps running after `--state=` loads, pass `--frame-stats`:
the flips in each emulated second since the load. A stuck or crashed game shows as zeros or a short list, and a
steady game as a flat 30 or 60. The state's load result is printed too, and a failed load exits 1.
Headless can also boot a savestate without its game: pass the `.ppst` as the file to run, without `--state=`.
The state says which game it's from and whether that was booted from an ISO, a PBP or an ELF, so compat.ini and
the game's config apply. An ISO game gets a disc with nothing on it: files it had open are dropped when the state
loads, and opening any file fails. Most games keep running for a few seconds without the disc, enough to measure,
but a state taken while the game streams music or video, or just before it loads a level, soon hits those errors,
so save perf states in the middle of gameplay. Only states saved since the disc ID was added to them (`SaveStart`
version 6) boot this way. The app doesn't: it refuses a `.ppst` as before.
To get past menus, `--press=SECONDS:MASK[:DURATION],...` holds buttons at emulated times (`MASK` is a sceCtrl
mask, `0x8` START and `0x4000` CROSS, held 0.15 seconds by default), and `--screenshot-at=SECONDS:FILE,...` saves
screenshots along the way.
+12
View File
@@ -1042,6 +1042,17 @@ int main(int argc, const char* argv[]) {
testFilenames.end()
);
// A savestate on its own boots without its game, and then loads.
std::string stateBootPath;
if (testFilenames.size() == 1 && endsWithNoCase(testFilenames[0], ".ppst")) {
stateBootPath = testFilenames[0];
if (stateToLoad) {
fprintf(stderr, "--state can't be used when booting a state\n");
return 1;
}
stateToLoad = stateBootPath.c_str();
}
g_Config.bEnableLogging = (fullLog || outputDebugStringLog);
g_logManager.Init(&g_Config.bEnableLogging, outputDebugStringLog);
@@ -1333,6 +1344,7 @@ int main(int argc, const char* argv[]) {
coreParameter.mountRoot = mountRoot.empty() ? Path() : Path(mountRoot);
coreParameter.startBreak = false;
coreParameter.headLess = true;
coreParameter.bootFromState = !stateBootPath.empty();
coreParameter.dumpCompat = cmdLineOptions.dumpCompat.value_or(false);
if (cmdLineOptions.disableGPUFeatures.has_value()) {
std::vector<std::string> names;