From 09f65a860c93bfbc797a9180d86ef9e4591cb341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 1 Nov 2026 09:54:54 +0100 Subject: [PATCH] SaveState: Store what the game was booted from, and read a state's start without loading it SaveStart (still version 6) also stores whether the game was booted from an ISO, a PBP, an ELF or a disc directory. PeekBootInfo decompresses only the start of a state to read them with the disc ID, so a boot from a state alone can be set up before the state is loaded. Co-Authored-By: Claude Opus 5.5 --- Common/Serialize/Serializer.cpp | 27 +++++++++++++-- Common/Serialize/Serializer.h | 3 ++ Core/SaveState.cpp | 58 +++++++++++++++++++++++++++++++-- Core/SaveState.h | 19 +++++++++++ unittest/UnitTest.cpp | 50 ++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/Common/Serialize/Serializer.cpp b/Common/Serialize/Serializer.cpp index d6c983e887..065c05d71a 100644 --- a/Common/Serialize/Serializer.cpp +++ b/Common/Serialize/Serializer.cpp @@ -403,7 +403,8 @@ CChunkFileReader::Error CChunkFileReader::GetFileTitle(const Path &filename, std } // Streams the file through the decompressor, so the compressed data is never all in memory at once. -static bool DecompressZstdFromFile(File::IOFile &file, size_t compressedSize, u8 *out, size_t outSize) { +// With onlyStart, stops once out is full, rather than requiring the data to be exactly that long. +static bool DecompressZstdFromFile(File::IOFile &file, size_t compressedSize, u8 *out, size_t outSize, bool onlyStart = false) { ZSTD_DCtx *ctx = ZSTD_createDCtx(); if (!ctx) { return false; @@ -414,6 +415,9 @@ static bool DecompressZstdFromFile(File::IOFile &file, size_t compressedSize, u8 size_t ret = 1; bool success = true; while (left > 0 && success) { + if (onlyStart && output.pos == outSize) { + break; + } const size_t chunk = std::min(left, in.size()); if (!file.ReadBytes(in.data(), chunk)) { ERROR_LOG(Log::SaveState, "ChunkReader: Error reading file"); @@ -422,7 +426,7 @@ static bool DecompressZstdFromFile(File::IOFile &file, size_t compressedSize, u8 } left -= chunk; ZSTD_inBuffer input{ in.data(), chunk, 0 }; - while (input.pos < input.size) { + while (input.pos < input.size && !(onlyStart && output.pos == outSize)) { const size_t inPos = input.pos; const size_t outPos = output.pos; ret = ZSTD_decompressStream(ctx, &output, &input); @@ -434,6 +438,9 @@ static bool DecompressZstdFromFile(File::IOFile &file, size_t compressedSize, u8 } } ZSTD_freeDCtx(ctx); + if (onlyStart) { + return success && output.pos == outSize; + } // ret is 0 once a frame is completely decoded. return success && ret == 0 && output.pos == outSize; } @@ -465,6 +472,22 @@ static bool CompressZstd(const u8 *data, size_t size, std::vector *out) { return !ZSTD_isError(remaining); } +CChunkFileReader::Error CChunkFileReader::LoadFileStart(const Path &filename, u8 *out, size_t size) { + File::IOFile pFile(filename, "rb"); + SChunkHeader header; + Error err = LoadFileHeader(pFile, header, nullptr); + if (err != ERROR_NONE) { + return err; + } + if (!header.Compress) { + return size <= header.ExpectedSize && pFile.ReadBytes(out, size) ? ERROR_NONE : ERROR_BAD_FILE; + } + if (SerializeCompressType(header.Compress) != SerializeCompressType::ZSTD || size > header.UncompressedSize) { + return ERROR_BAD_FILE; + } + return DecompressZstdFromFile(pFile, header.ExpectedSize, out, size, true) ? ERROR_NONE : ERROR_BAD_FILE; +} + CChunkFileReader::Error CChunkFileReader::LoadFile(const Path &filename, std::string *gitVersion, u8 *&_buffer, size_t &sz) { if (!File::Exists(filename)) { ERROR_LOG(Log::SaveState, "ChunkReader: File doesn't exist"); diff --git a/Common/Serialize/Serializer.h b/Common/Serialize/Serializer.h index a56ad6e8be..8a27297dc5 100644 --- a/Common/Serialize/Serializer.h +++ b/Common/Serialize/Serializer.h @@ -360,6 +360,9 @@ public: // The file half of Load() and Save(), for doing it away from the state itself. LoadFile hands back // a new[]'d buffer; SaveFile takes ownership of a malloc'd one (as from MeasureAndSavePtr). static Error LoadFile(const Path &filename, std::string *gitVersion, u8 *&buffer, size_t &sz); + // Reads only the first size bytes of the state, decompressing no more than that. Not for snappy + // compressed (old) states. + static Error LoadFileStart(const Path &filename, u8 *out, size_t size); static Error SaveFile(const Path &filename, const std::string &title, const char *gitVersion, u8 *buffer, size_t sz); private: diff --git a/Core/SaveState.cpp b/Core/SaveState.cpp index e5d0ded280..3cc486e86d 100644 --- a/Core/SaveState.cpp +++ b/Core/SaveState.cpp @@ -204,6 +204,26 @@ int g_screenshotFailures; // we should probably just read back memory then instead (GTA for example). But this is a really unimportant edge case, // when in-game it's just not an issue. + static constexpr size_t DISC_ID_SIZE = 16; + static constexpr size_t DISC_VERSION_SIZE = 8; + + static BootType GetBootType(IdentifiedFileType type) { + switch (type) { + case IdentifiedFileType::PSP_ISO: + case IdentifiedFileType::PSP_ISO_NP: + return BootType::ISO; + case IdentifiedFileType::PSP_PBP: + case IdentifiedFileType::PSP_PBP_DIRECTORY: + return BootType::PBP; + case IdentifiedFileType::PSP_ELF: + return BootType::ELF; + case IdentifiedFileType::PSP_DISC_DIRECTORY: + return BootType::DISC_DIRECTORY; + default: + return BootType::UNKNOWN; + } + } + void SaveStart::DoState(PointerWrap &p) { // Nothing may still be writing PSP memory while it's saved, or be left to write into what's loaded. __UtilityWaitForIO(); @@ -217,15 +237,19 @@ int g_screenshotFailures; return; if (s >= 6) { - // Fixed width and first, so the game a state belongs to can be read without loading it. - char discID[16]{}; - char discVersion[8]{}; + // Fixed width and first, so the game a state belongs to can be read without loading it + // (see PeekBootInfo). + char discID[DISC_ID_SIZE]{}; + char discVersion[DISC_VERSION_SIZE]{}; + u8 bootType = 0; if (p.mode != p.MODE_READ) { truncate_cpy(discID, g_paramSFO.GetValueString("DISC_ID").c_str()); truncate_cpy(discVersion, g_paramSFO.GetValueString("DISC_VERSION").c_str()); + bootType = (u8)GetBootType(PSP_CoreParameter().fileType); } p.DoVoid(discID, sizeof(discID)); p.DoVoid(discVersion, sizeof(discVersion)); + Do(p, bootType); if (p.mode == p.MODE_READ) { const std::string stateDiscID(discID, strnlen(discID, sizeof(discID))); const std::string runningDiscID = g_paramSFO.GetValueString("DISC_ID"); @@ -597,6 +621,34 @@ int g_screenshotFailures; return title + " (" + filename + ")"; } + bool PeekBootInfo(const Path &filename, BootInfo *info, std::string *error) { + // The start of SaveStart, laid out as PointerWrap::Section writes it: a 16 byte name and the + // version, then the fields from version 6. + u8 start[16 + 4 + DISC_ID_SIZE + DISC_VERSION_SIZE + 1]; + if (CChunkFileReader::LoadFileStart(filename, start, sizeof(start)) != CChunkFileReader::ERROR_NONE) { + *error = "Couldn't read the state"; + return false; + } + char name[16]{}; + strncpy(name, "SaveStart", sizeof(name)); + if (memcmp(start, name, sizeof(name)) != 0) { + *error = "Not a savestate"; + return false; + } + s32 version; + memcpy(&version, start + 16, sizeof(version)); + if (version < 6) { + *error = "The state is too old to say which game it's from"; + return false; + } + const char *discID = (const char *)start + 20; + const char *discVersion = discID + DISC_ID_SIZE; + info->discID.assign(discID, strnlen(discID, DISC_ID_SIZE)); + info->discVersion.assign(discVersion, strnlen(discVersion, DISC_VERSION_SIZE)); + info->bootType = (BootType)start[sizeof(start) - 1]; + return true; + } + std::string GetTitle(const Path &filename) { std::string title; if (CChunkFileReader::GetFileTitle(filename, &title) == CChunkFileReader::ERROR_NONE) { diff --git a/Core/SaveState.h b/Core/SaveState.h index 33840e41ef..8ecbd6e101 100644 --- a/Core/SaveState.h +++ b/Core/SaveState.h @@ -94,6 +94,25 @@ namespace SaveState { std::string GetTitle(const Path &filename); + // What the game was booted from. Stored in states, so the values are fixed. + enum class BootType : u8 { + UNKNOWN = 0, + ISO = 1, + PBP = 2, + ELF = 3, + DISC_DIRECTORY = 4, + }; + + // What a boot needs to know about a state before it's loaded. + struct BootInfo { + std::string discID; + std::string discVersion; + BootType bootType = BootType::UNKNOWN; + }; + + // Reads only the start of the state. Fails for states older than the disc ID in them. + bool PeekBootInfo(const Path &filename, BootInfo *info, std::string *error); + // Load the specified file into the current state (async.) The file is read while emulation goes // on, and the state applied in a later Process(). // Returns true if queued. The callback runs from Process(), once the state has been applied. diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index c5a4722270..521a350618 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -119,6 +119,7 @@ #include "Core/FileSystems/ISOFileSystem.h" #include "Core/FileSystems/DirectoryFileSystem.h" #include "Core/FileSystems/MetaFileSystem.h" +#include "Core/SaveState.h" #include "Core/HLE/sceUmd.h" #include "Core/Loaders.h" #include "Core/MemMap.h" @@ -293,6 +294,54 @@ bool TestSaveStateMounts() { return true; } +// The start of a state as SaveStart writes it, and a little more. +struct PeekTestState { + int version; + void DoState(PointerWrap &p) { + auto s = p.Section("SaveStart", 1, version); + if (!s) + return; + if (s >= 6) { + char discID[16] = "ULUS12345"; + char discVersion[8] = "1.01"; + u8 bootType = (u8)SaveState::BootType::PBP; + p.DoVoid(discID, sizeof(discID)); + p.DoVoid(discVersion, sizeof(discVersion)); + Do(p, bootType); + } + std::vector rest(100000, 0x55); + Do(p, rest); + } +}; + +static bool SavePeekTestState(const Path &path, int version) { + PeekTestState state{ version }; + u8 *data = nullptr; + size_t size = 0; + if (CChunkFileReader::MeasureAndSavePtr(state, &data, &size) != CChunkFileReader::ERROR_NONE) { + return false; + } + return CChunkFileReader::SaveFile(path, "Test", "v1.0", data, size) == CChunkFileReader::ERROR_NONE; +} + +bool TestSaveStatePeek() { + const Path path = UnitTestTempPath("unittest_peek.ppst"); + SaveState::BootInfo info; + std::string error; + + EXPECT_TRUE(SavePeekTestState(path, 6)); + EXPECT_TRUE(SaveState::PeekBootInfo(path, &info, &error)); + EXPECT_EQ_STR(info.discID, std::string("ULUS12345")); + EXPECT_EQ_STR(info.discVersion, std::string("1.01")); + EXPECT_TRUE(info.bootType == SaveState::BootType::PBP); + + EXPECT_TRUE(SavePeekTestState(path, 5)); + EXPECT_FALSE(SaveState::PeekBootInfo(path, &info, &error)); + + File::Delete(path); + return true; +} + bool TestInetPton() { uint8_t ip4[4]; EXPECT_EQ_INT(net::inet_pton(AF_INET, "1.2.3.4", ip4), 1); @@ -4229,6 +4278,7 @@ TestItem availableTests[] = { TEST_ITEM(StackWalk), TEST_ITEM(InetPton), TEST_ITEM(SaveStateMounts), + TEST_ITEM(SaveStatePeek), }; int main(int argc, const char *argv[]) {