From 8122a5ec70e6bf4b35013ee3a95e5c4e4077fca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 1 Nov 2026 11:04:26 +0100 Subject: [PATCH] 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 --- Core/CoreParameter.h | 2 ++ Core/HLE/sceKernelModule.cpp | 23 ++++++++++++++ Core/HLE/sceKernelModule.h | 2 ++ Core/PSPLoaders.cpp | 60 ++++++++++++++++++++++++++++++++++++ Core/PSPLoaders.h | 4 +++ Core/System.cpp | 17 +++++++++- docs/debugging.md | 8 +++++ headless/Headless.cpp | 12 ++++++++ 8 files changed, 127 insertions(+), 1 deletion(-) diff --git a/Core/CoreParameter.h b/Core/CoreParameter.h index 419af33daf..16012a1ed1 100644 --- a/Core/CoreParameter.h +++ b/Core/CoreParameter.h @@ -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. diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index 54131b836e..6b21faed0d 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -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]); diff --git a/Core/HLE/sceKernelModule.h b/Core/HLE/sceKernelModule.h index 1ab357464d..32541b885c 100644 --- a/Core/HLE/sceKernelModule.h +++ b/Core/HLE/sceKernelModule.h @@ -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 *hostArgs = nullptr); // The VSH (the XMB) is what's running, not a game it launched. diff --git a/Core/PSPLoaders.cpp b/Core/PSPLoaders.cpp index 571b7acdd5..82cdd235c1 100644 --- a/Core/PSPLoaders.cpp +++ b/Core/PSPLoaders.cpp @@ -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(&pspFileSystem, std::make_shared()); + auto blockSystem = std::make_shared(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(&pspFileSystem, fileLoader, "data.ppdmp"); pspFileSystem.Mount("disc0:", umd); diff --git a/Core/PSPLoaders.h b/Core/PSPLoaders.h index 7592b43427..b4cddd6c86 100644 --- a/Core/PSPLoaders.h +++ b/Core/PSPLoaders.h @@ -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); diff --git a/Core/System.cpp b/Core/System.cpp index 420812f733..133f58de20 100644 --- a/Core/System.cpp +++ b/Core/System.cpp @@ -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); diff --git a/docs/debugging.md b/docs/debugging.md index c10ff6f092..a3777388e0 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -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. diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 23b5d9a838..77a7ebd2b4 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -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 names;