Files
ppsspp/Core/SaveState.cpp
T
Henrik RydgårdandClaude Opus 5 255896e89f Re-check hardcore mode when a savestate op is actually applied
The check lived only in Enqueue, but operations don't run there - they're
queued and applied later by Process(). During boot, HardcoreModeActive() reads
false even when hardcore is on, since it requires rc_client_is_processing_required(),
which only becomes true once RetroAchievements has finished identifying the game
asynchronously. Anything queued in that window passed the check, and was then
applied by Process() after identification completed and hardcore came up.

Auto-load wasn't even a race: EmuScreen::bootComplete() calls Achievements::SetGame(),
which starts the identify, and then checks HardcoreModeActive() a few lines below -
always false at that point. So "Auto load savestate" quietly worked in hardcore mode.
--state and a load-state hotkey pressed during boot got through the same way.

Re-checking per operation in Process() covers every entry point at once, and by
then identification has finished, so the answer is authoritative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgACRqkQNpfJQ4fjwyoEup
2026-09-05 13:27:28 -06:00

1010 lines
32 KiB
C++

// Copyright (c) 2012- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <algorithm>
#include <vector>
#include <thread>
#include <mutex>
#include <string>
#include <set>
#include "Common/Data/Text/I18n.h"
#include "Common/Thread/ThreadUtil.h"
#include "Common/Data/Text/Parsers.h"
#include "Common/System/System.h"
#include "Common/File/FileUtil.h"
#include "Common/File/DirListing.h"
#include "Common/Serialize/Serializer.h"
#include "Common/Serialize/SerializeFuncs.h"
#include "Common/StringUtils.h"
#include "Common/TimeUtil.h"
#include "Core/SaveState.h"
#include "Core/Config.h"
#include "Core/Core.h"
#include "Core/CoreTiming.h"
#include "Core/Screenshot.h"
#include "Core/System.h"
#include "Core/SaveStateRewind.h"
#include "Core/FileSystems/MetaFileSystem.h"
#include "Core/ELF/ParamSFO.h"
#include "Core/HLE/HLE.h"
#include "Core/HLE/sceNet.h"
#include "Core/HLE/ReplaceTables.h"
#include "Core/HLE/sceDisplay.h"
#include "Core/HLE/sceKernel.h"
#include "Core/HLE/sceUtility.h"
#include "Core/MemMap.h"
#include "Core/MIPS/JitCommon/JitBlockCache.h"
#include "Core/RetroAchievements.h"
#include "HW/MemoryStick.h"
#ifndef MOBILE_DEVICE
#include "Core/AVIDump.h"
#include "Core/HLE/__sceAudio.h"
#endif
// Slot number is visual only, -2 will display special message
constexpr int LOAD_UNDO_SLOT = -2;
constexpr int SCREENSHOT_FAILURE_RETRIES = 6;
static const char * const STATE_EXTENSION = "ppst";
static const char * const UNDO_STATE_EXTENSION = "undo.ppst";
static const char * const UNDO_SCREENSHOT_EXTENSION = "undo.jpg";
static const char * const LOAD_UNDO_NAME = "load_undo.ppst";
namespace SaveState {
// Used for "confirm exit if you haven't saved in a while"
double g_lastSaveTime = -1.0;
static bool needsProcess = false;
static bool needsRestart = false;
static std::mutex mutex;
static bool hasLoadedState = false;
static const int STALE_STATE_USES = 2;
// 4 hours of total gameplay since the virtual PSP started the game.
static const u64 STALE_STATE_TIME = 4 * 3600 * 1000000ULL;
static int saveStateGeneration = 0;
static int saveDataGeneration = 0;
static int lastSaveDataGeneration = 0;
static std::string saveStateInitialGitVersion = "";
static StateRingbuffer rewindStates;
// Updated by Rescan. This is to avoid calling Exists a lot of times, which could be slow
// on some Android devices when we allow many save states.
static double g_lastRescan = 0.0;
static std::map<std::string, uint64_t, std::less<>> g_files; // the value is the mod-time.
struct SaveStart {
void DoState(PointerWrap &p);
};
enum class OperationType {
Save,
Load,
Verify,
Rewind,
};
struct Operation {
// The slot number is for visual purposes only. Set to -1 for operations where we don't display a message for example.
Operation(OperationType t, const Path &f, int slot_, Callback cb)
: type(t), path(f), callback(cb), slot(slot_) {}
OperationType type;
Path path;
Callback callback;
int slot;
};
static std::vector<Operation> g_pendingOperations;
int g_screenshotFailures;
CChunkFileReader::Error SaveToRam(std::vector<u8> &data) {
SaveStart state;
return CChunkFileReader::MeasureAndSavePtr(state, &data);
}
CChunkFileReader::Error LoadFromRam(std::vector<u8> &data, std::string *errorString) {
SaveStart state;
return CChunkFileReader::LoadPtr(&data[0], data.size(), state, errorString);
}
// TODO: Should this be configurable?
// Should only fail if the game hasn't created a framebuffer yet. Some games play video without a framebuffer,
// 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.
void SaveStart::DoState(PointerWrap &p) {
auto s = p.Section("SaveStart", 1, 3);
if (!s)
return;
if (s >= 2) {
// This only increments on save, of course.
++saveStateGeneration;
Do(p, saveStateGeneration);
// This saves the first git version to create this save state (or generation of save states.)
if (saveStateInitialGitVersion.empty())
saveStateInitialGitVersion = PPSSPP_GIT_VERSION;
Do(p, saveStateInitialGitVersion);
} else {
saveStateGeneration = 1;
}
if (s >= 3) {
// Keep track of savedata (not save states) too.
Do(p, saveDataGeneration);
} else {
saveDataGeneration = 0;
}
// Gotta do CoreTiming before HLE, but from v3 we've moved it after the memory stuff.
if (s <= 2) {
CoreTiming::DoState(p);
}
// Memory is a bit tricky when jit is enabled, since there's emuhacks in it.
// These must be saved before copying out memory and restored after.
auto savedReplacements = SaveAndClearReplacements();
if (MIPSComp::jit && p.mode == p.MODE_WRITE) {
if (MIPSComp::jit) {
std::vector<u32> savedBlocks;
savedBlocks = MIPSComp::jit->SaveAndClearEmuHackOps();
Memory::DoState(p);
MIPSComp::jit->RestoreSavedEmuHackOps(savedBlocks);
} else {
Memory::DoState(p);
}
} else {
Memory::DoState(p);
}
if (s >= 3) {
CoreTiming::DoState(p);
}
// Don't bother restoring if reading, we'll deal with that in KernelModuleDoState.
// In theory, different functions might have been runtime loaded in the state.
if (p.mode != p.MODE_READ) {
RestoreSavedReplacements(savedReplacements);
}
MemoryStick_DoState(p);
currentMIPS->DoState(p);
HLEDoState(p);
__KernelDoState(p);
Achievements::DoState(p);
// Kernel object destructors might close open files, so do the filesystem last.
pspFileSystem.DoState(p);
}
// Hardcore mode bans loading savestates outright, and saving too unless the user opted
// back into that.
bool BannedInHardcoreMode(OperationType type) {
if (!Achievements::HardcoreModeActive()) {
return false;
}
return !(g_Config.bAchievementsSaveStateInHardcoreMode && type == OperationType::Save);
}
void Enqueue(const SaveState::Operation &op) {
if (!NetworkAllowSaveState() || BannedInHardcoreMode(op.type)) {
return;
}
std::lock_guard<std::mutex> guard(mutex);
g_pendingOperations.push_back(op);
// Don't actually run it until next frame.
// It's possible there might be a duplicate but it won't hurt us.
needsProcess = true;
}
void Load(const Path &filename, int slot, Callback callback) {
if (!NetworkAllowSaveState()) {
return;
}
rewindStates.NotifyState();
if (coreState == CoreState::CORE_RUNTIME_ERROR)
Core_Break(BreakReason::SavestateLoad, 0);
Enqueue(Operation(OperationType::Load, filename, slot, callback));
}
void Save(const Path &filename, int slot, Callback callback) {
if (!NetworkAllowSaveState()) {
return;
}
rewindStates.NotifyState();
if (coreState == CoreState::CORE_RUNTIME_ERROR)
Core_Break(BreakReason::SavestateSave, 0);
Enqueue(Operation(OperationType::Save, filename, slot, callback));
}
void Verify(Callback callback) {
Enqueue(Operation(OperationType::Verify, Path(), -1, callback));
}
void Rewind(Callback callback) {
if (coreState == CoreState::CORE_RUNTIME_ERROR)
Core_Break(BreakReason::SavestateRewind, 0);
Enqueue(Operation(OperationType::Rewind, Path(), -1, callback));
}
bool CanRewind() {
return !rewindStates.Empty();
}
// Slot utilities
std::string AppendSlotTitle(const std::string &filename, const std::string &title) {
char slotChar = 0;
auto detectSlot = [&slotChar, filename](const std::string &ext) {
if (!endsWith(filename, std::string(".") + ext)) {
return false;
}
// Usually these are slots, let's check the slot # after the last '_'.
size_t slotNumPos = filename.find_last_of('_');
if (slotNumPos == filename.npos) {
return false;
}
const size_t extLength = ext.length() + 1;
// If we take out the extension, '_', etc. we should be left with only a single digit.
if (slotNumPos + 1 + extLength != filename.length() - 1) {
return false;
}
slotChar = filename[slotNumPos + 1];
if (slotChar < '0' || slotChar > '8') {
return false;
}
// Change from zero indexed to human friendly.
slotChar++;
return true;
};
if (detectSlot(STATE_EXTENSION)) {
return StringFromFormat("%s (%c)", title.c_str(), slotChar);
}
if (detectSlot(UNDO_STATE_EXTENSION)) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
// Allow the number to be positioned where it makes sense.
std::string undo(sy->T("undo %c"));
return title + " (" + StringFromFormat(undo.c_str(), slotChar) + ")";
}
// Couldn't detect, use the filename.
return title + " (" + filename + ")";
}
std::string GetTitle(const Path &filename) {
std::string title;
if (CChunkFileReader::GetFileTitle(filename, &title) == CChunkFileReader::ERROR_NONE) {
if (title.empty()) {
return filename.GetFilename();
}
return AppendSlotTitle(filename.GetFilename(), title);
}
// The file can't be loaded - let's note that.
auto sy = GetI18NCategory(I18NCat::SYSTEM);
return filename.GetFilename() + " " + std::string(sy->T("(broken)"));
}
// TODO: gameFilename is ignored!
// This means that this must always be called in-game, otherwise it will not work!
//
// This particular ID string generation is specific for save states.
static std::string GenerateFullDiscId(const ParamSFOData &paramSFO) {
std::string discId = paramSFO.GetValueString("DISC_ID");
std::string discVer = paramSFO.GetValueString("DISC_VERSION");
if (discId.empty()) {
// Should never happen.
discId = g_paramSFO.GenerateFakeID(Path());
discVer = "1.00";
}
return StringFromFormat("%s_%s", discId.c_str(), discVer.c_str());
}
std::string GetGamePrefix(const ParamSFOData &paramSFO) {
return GenerateFullDiscId(paramSFO);
}
// The prefix is always based on GenerateFullDiscId. So we can find these by scanning, too.
std::string GenerateSaveSlotFilename(std::string_view gamePrefix, int slot, const char *extension) {
return StringFromFormat("%.*s_%d.%s", STR_VIEW(gamePrefix), slot, extension);
}
Path GenerateSaveSlotPath(std::string_view gamePrefix, int slot, const char *extension) {
std::string filename = GenerateSaveSlotFilename(gamePrefix, slot, extension);
return GetSysDirectory(DIRECTORY_SAVESTATE) / filename;
}
int GetCurrentSlot() {
return g_Config.iCurrentStateSlot;
}
void PrevSlot() {
g_Config.iCurrentStateSlot = (g_Config.iCurrentStateSlot - 1 + g_Config.iSaveStateSlotCount) % g_Config.iSaveStateSlotCount;
}
void NextSlot() {
g_Config.iCurrentStateSlot = (g_Config.iCurrentStateSlot + 1) % g_Config.iSaveStateSlotCount;
}
static void DeleteIfExists(const Path &fn) {
// Just avoiding error messages.
File::Delete(fn, true);
}
static void RenameIfExists(const Path &from, const Path &to) {
if (File::Exists(from)) {
File::Rename(from, to);
}
}
static void SwapIfExists(const Path &from, const Path &to) {
Path temp = from.WithExtraExtension(".tmp");
if (File::Exists(from)) {
File::Rename(from, temp);
File::Rename(to, from);
File::Rename(temp, to);
}
}
static void ScheduleSaveScreenshot(const Path &path) {
std::lock_guard<std::mutex> guard(mutex);
g_screenshotFailures = 0;
// Savestate thumbnails don't need to be bigger.
constexpr int maxResMultiplier = 2;
ScheduleScreenshot(path, ScreenshotFormat::JPG, ScreenshotType::Display, maxResMultiplier, [path](ScreenshotResult result) {
switch (result) {
case ScreenshotResult::ScreenshotNotPossible:
// Try again soon, for a short while.
WARN_LOG(Log::SaveState, "Failed to take a screenshot for the savestate! (%s) The savestate will lack an icon.", path.c_str());
if (coreState != CORE_STEPPING_CPU && g_screenshotFailures++ < SCREENSHOT_FAILURE_RETRIES) {
// Requeue for next frame (if we were stepping, no point, will just spam errors quickly).
ScheduleSaveScreenshot(path);
}
break;
case ScreenshotResult::FailedToWriteFile:
case ScreenshotResult::Success:
g_screenshotFailures = 0;
break;
}
});
}
void LoadSlot(std::string_view gamePrefix, int slot, Callback callback) {
if (!NetworkAllowSaveState()) {
return;
}
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
if (!fn.empty()) {
// This add only 1 extra state, should we just always enable it?
if (g_Config.bEnableStateUndo) {
Path backup = GetSysDirectory(DIRECTORY_SAVESTATE) / LOAD_UNDO_NAME;
std::string prefix(gamePrefix);
auto saveCallback = [prefix, fn, backup, slot, callback](Status status, std::string_view message, std::string_view metadata) {
if (status != Status::FAILURE) {
DeleteIfExists(backup);
File::Rename(backup.WithExtraExtension(".tmp"), backup);
g_Config.sStateLoadUndoGame = prefix;
g_Config.Save("Saving config for savestate last load undo");
} else {
ERROR_LOG(Log::SaveState, "Saving load undo state failed: %.*s", (int)message.size(), message.data());
}
Load(fn, slot, callback);
};
if (!backup.empty()) {
Save(backup.WithExtraExtension(".tmp"), LOAD_UNDO_SLOT, saveCallback);
} else {
ERROR_LOG(Log::SaveState, "Saving load undo state failed. Error in the file system.");
Load(fn, slot, callback);
}
} else {
Load(fn, slot, callback);
}
} else {
if (callback) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
callback(Status::FAILURE, sy->T("Failed to load state. Error in the file system."), "");
}
}
}
bool UndoLoad(std::string_view gamePrefix, Callback callback) {
if (!NetworkAllowSaveState()) {
return false;
}
if (g_Config.sStateLoadUndoGame != gamePrefix) {
if (callback) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
callback(Status::FAILURE, sy->T("Error: load undo state is from a different game"), "");
}
return false;
}
Path fn = GetSysDirectory(DIRECTORY_SAVESTATE) / LOAD_UNDO_NAME;
if (!fn.empty()) {
Load(fn, LOAD_UNDO_SLOT, callback);
return true;
} else {
if (callback) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
callback(Status::FAILURE, sy->T("Failed to load state for load undo. Error in the file system."), "");
}
return false;
}
}
void SaveSlot(std::string_view gamePrefix, int slot, Callback callback) {
if (!NetworkAllowSaveState()) {
return;
}
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
Path fnUndo = GenerateSaveSlotPath(gamePrefix, slot, UNDO_STATE_EXTENSION);
if (!fn.empty()) {
Path shot = GenerateSaveSlotPath(gamePrefix, slot, SCREENSHOT_EXTENSION);
std::string prefix(gamePrefix);
auto renameCallback = [fn, fnUndo, prefix, slot, callback](Status status, std::string_view message, std::string_view metadata) {
if (status != Status::FAILURE) {
if (g_Config.bEnableStateUndo) {
DeleteIfExists(fnUndo);
RenameIfExists(fn, fnUndo);
g_Config.sStateUndoLastSaveGame = prefix;
g_Config.iStateUndoLastSaveSlot = slot;
g_Config.Save("Saving config for savestate last save undo");
} else {
DeleteIfExists(fn);
}
File::Rename(fn.WithExtraExtension(".tmp"), fn);
}
if (callback) {
callback(status, message, "");
}
};
// Let's also create a screenshot.
if (g_Config.bEnableStateUndo) {
Path shotUndo = GenerateSaveSlotPath(gamePrefix, slot, UNDO_SCREENSHOT_EXTENSION);
DeleteIfExists(shotUndo);
RenameIfExists(shot, shotUndo);
}
ScheduleSaveScreenshot(shot);
Save(fn.WithExtraExtension(".tmp"), slot, renameCallback);
} else {
if (callback) {
auto sy = GetI18NCategory(I18NCat::SYSTEM);
callback(Status::FAILURE, sy->T("Failed to save state. Error in the file system."), "");
}
}
Rescan(gamePrefix);
}
bool UndoSaveSlot(std::string_view gamePrefix, int slot) {
if (!NetworkAllowSaveState()) {
return false;
}
Path fnUndo = GenerateSaveSlotPath(gamePrefix, slot, UNDO_STATE_EXTENSION);
// Do nothing if there's no undo.
if (File::Exists(fnUndo)) {
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
Path shot = GenerateSaveSlotPath(gamePrefix, slot, SCREENSHOT_EXTENSION);
Path shotUndo = GenerateSaveSlotPath(gamePrefix, slot, UNDO_SCREENSHOT_EXTENSION);
// Swap them so they can undo again to redo. Mistakes happen.
SwapIfExists(shotUndo, shot);
SwapIfExists(fnUndo, fn);
return true;
}
Rescan(gamePrefix);
return false;
}
void DeleteSlot(std::string_view gamePrefix, int slot) {
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
Path shot = GenerateSaveSlotPath(gamePrefix, slot, SCREENSHOT_EXTENSION);
if (File::Exists(fn)) {
DeleteIfExists(fn);
DeleteIfExists(shot);
}
Rescan(gamePrefix);
}
bool UndoLastSave(std::string_view gamePrefix) {
if (!NetworkAllowSaveState()) {
return false;
}
if (g_Config.sStateUndoLastSaveGame != gamePrefix)
return false;
bool retval = UndoSaveSlot(gamePrefix, g_Config.iStateUndoLastSaveSlot);
Rescan(gamePrefix);
return retval;
}
void Rescan(std::string_view gamePrefix) {
// Currently nothing to do here.
// On Android this would be important to rescan the save state directory.
std::vector<File::FileInfo> file;
std::string prefix(gamePrefix);
prefix += "_";
if (!File::GetFilesInDir(GetSysDirectory(DIRECTORY_SAVESTATE), &file, nullptr, 0, prefix)) {
ERROR_LOG(Log::System, "Failed to list savestate directory!");
return;
}
g_files.clear();
for (const auto &f : file) {
g_files[f.name] = f.mtime;
}
g_lastRescan = time_now_d();
}
static bool SaveStateFileExists(std::string_view gamePrefix, int slot, const char *extension) {
return g_files.find(GenerateSaveSlotFilename(gamePrefix, slot, extension)) != g_files.end();
}
bool HasSaveInSlot(std::string_view gamePrefix, int slot) {
return SaveStateFileExists(gamePrefix, slot, STATE_EXTENSION);
}
bool HasUndoSaveInSlot(std::string_view gamePrefix, int slot) {
return SaveStateFileExists(gamePrefix, slot, UNDO_STATE_EXTENSION);
}
bool HasUndoLastSave(std::string_view gamePrefix) {
if (g_Config.sStateUndoLastSaveGame != gamePrefix)
return false;
return HasUndoSaveInSlot(gamePrefix, g_Config.iStateUndoLastSaveSlot);
}
bool HasScreenshotInSlot(std::string_view gamePrefix, int slot) {
return SaveStateFileExists(gamePrefix, slot, SCREENSHOT_EXTENSION);
}
bool HasUndoLoad(std::string_view gamePrefix) {
Path fn = GetSysDirectory(DIRECTORY_SAVESTATE) / LOAD_UNDO_NAME;
return File::Exists(fn) && g_Config.sStateLoadUndoGame == gamePrefix;
}
bool operator < (const tm &t1, const tm &t2) {
if (t1.tm_year < t2.tm_year) return true;
if (t1.tm_year > t2.tm_year) return false;
if (t1.tm_mon < t2.tm_mon) return true;
if (t1.tm_mon > t2.tm_mon) return false;
if (t1.tm_mday < t2.tm_mday) return true;
if (t1.tm_mday > t2.tm_mday) return false;
if (t1.tm_hour < t2.tm_hour) return true;
if (t1.tm_hour > t2.tm_hour) return false;
if (t1.tm_min < t2.tm_min) return true;
if (t1.tm_min > t2.tm_min) return false;
if (t1.tm_sec < t2.tm_sec) return true;
if (t1.tm_sec > t2.tm_sec) return false;
return false;
}
bool operator > (const tm &t1, const tm &t2) {
if (t1.tm_year > t2.tm_year) return true;
if (t1.tm_year < t2.tm_year) return false;
if (t1.tm_mon > t2.tm_mon) return true;
if (t1.tm_mon < t2.tm_mon) return false;
if (t1.tm_mday > t2.tm_mday) return true;
if (t1.tm_mday < t2.tm_mday) return false;
if (t1.tm_hour > t2.tm_hour) return true;
if (t1.tm_hour < t2.tm_hour) return false;
if (t1.tm_min > t2.tm_min) return true;
if (t1.tm_min < t2.tm_min) return false;
if (t1.tm_sec > t2.tm_sec) return true;
if (t1.tm_sec < t2.tm_sec) return false;
return false;
}
bool operator ! (const tm &t1) {
if (t1.tm_year || t1.tm_mon || t1.tm_mday || t1.tm_hour || t1.tm_min || t1.tm_sec) return false;
return true;
}
int GetNewestSlot(std::string_view gamePrefix) {
int newestSlot = -1;
int64_t newestTime = 0;
for (int i = 0; i < g_Config.iSaveStateSlotCount; i++) {
auto iter = g_files.find(GenerateSaveSlotFilename(gamePrefix, i, STATE_EXTENSION));
if (iter != g_files.end()) {
int64_t mod = iter->second;
if (mod > newestTime) {
newestTime = mod;
newestSlot = i;
}
}
}
return newestSlot;
}
int GetOldestSlot(std::string_view gamePrefix) {
int oldestSlot = -1;
int64_t oldestTime = INT64_MAX;
for (int i = 0; i < g_Config.iSaveStateSlotCount; i++) {
auto iter = g_files.find(GenerateSaveSlotFilename(gamePrefix, i, STATE_EXTENSION));
if (iter != g_files.end()) {
int64_t mod = iter->second;
if (mod < oldestTime) {
oldestTime = mod;
oldestSlot = i;
}
}
}
return oldestSlot;
}
static std::string GetSaveFileDateAsString(const std::string &fn) {
auto iter = g_files.find(fn);
if (iter == g_files.end()) {
return "";
}
time_t t = iter->second;
tm time;
localtime_r((time_t*)&t, &time);
char buf[256];
// TODO: Use local time format instead of the configured PSP format?
// Americans and some others might not like ISO standard :)
switch (g_Config.iDateFormat) {
case PSP_SYSTEMPARAM_DATE_FORMAT_YYYYMMDD:
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &time);
break;
case PSP_SYSTEMPARAM_DATE_FORMAT_MMDDYYYY:
strftime(buf, sizeof(buf), "%m-%d-%Y %H:%M:%S", &time);
break;
case PSP_SYSTEMPARAM_DATE_FORMAT_DDMMYYYY:
strftime(buf, sizeof(buf), "%d-%m-%Y %H:%M:%S", &time);
break;
default: // Should never happen
return "";
}
return buf;
}
std::string GetSlotDateAsString(std::string_view gamePrefix, int slot) {
std::string fn = GenerateSaveSlotFilename(gamePrefix, slot, STATE_EXTENSION);
return GetSaveFileDateAsString(fn);
}
std::vector<Operation> Flush() {
std::lock_guard<std::mutex> guard(mutex);
std::vector<Operation> copy = g_pendingOperations;
g_pendingOperations.clear();
return copy;
}
static bool HandleLoadFailure(bool wasRewinding, std::string *metadata) {
if (wasRewinding) {
WARN_LOG(Log::SaveState, "HandleLoadFailure - trying a rewind state.");
// Okay, first, let's give the next rewind state a shot - maybe we can at least not reset entirely.
// Actually, this seems like a bad thing - some systems can end up in bad states, like sceFont :(
CChunkFileReader::Error result;
do {
std::string errorString;
result = rewindStates.Restore(&errorString, metadata);
} while (result == CChunkFileReader::ERROR_BROKEN_STATE);
if (result == CChunkFileReader::ERROR_NONE) {
return true;
}
}
// We tried, our only remaining option is to reset the game.
needsRestart = true;
// Make sure we don't proceed to run anything yet.
coreState = CORE_NEXTFRAME;
return false;
}
bool HasLoadedState() {
return hasLoadedState;
}
bool IsStale() {
if (saveStateGeneration >= STALE_STATE_USES) {
return CoreTiming::GetGlobalTimeUs() > STALE_STATE_TIME;
}
return false;
}
bool IsOldVersion() {
if (saveStateInitialGitVersion.empty())
return false;
Version state(saveStateInitialGitVersion);
Version gitVer(PPSSPP_GIT_VERSION);
if (!state.IsValid() || !gitVer.IsValid())
return false;
return state < gitVer;
}
static Status TriggerLoadWarnings(std::string &callbackMessage) {
auto sc = GetI18NCategory(I18NCat::SCREEN);
if (g_Config.bHideStateWarnings)
return Status::SUCCESS;
if (IsStale()) {
// For anyone wondering why (too long to put on the screen in an osm):
// Using save states instead of saves simulates many hour play sessions.
// Sometimes this exposes game bugs that were rarely seen on real devices,
// because few people played on a real PSP for 10 hours straight.
callbackMessage = sc->T("Loaded. Save in game, restart, and load for less bugs.");
return Status::SUCCESS;
}
if (IsOldVersion()) {
// Save states also preserve bugs from old PPSSPP versions, so warn.
callbackMessage = sc->T("Loaded. Save in game, restart, and load for less bugs.");
return Status::SUCCESS;
}
// If the loaded state (saveDataGeneration) is older, the game may prevent saving again.
// This can happen with newer too, but ignore to/from 0 as a common likely safe case.
if (saveDataGeneration != lastSaveDataGeneration && saveDataGeneration != 0 && lastSaveDataGeneration != 0) {
if (saveDataGeneration < lastSaveDataGeneration)
callbackMessage = sc->T("Loaded. Game may refuse to save over newer savedata.");
else
callbackMessage = sc->T("Loaded. Game may refuse to save over different savedata.");
return Status::WARNING;
}
return Status::SUCCESS;
}
// NOTE: This can cause ending of the current renderpass, due to the readback needed for the screenshot.
// TODO: This should run the actual operations on a thread. While this returns true (for example), emulation
// *must* not run further, in order not to disturb the current state operation.
void Process() {
rewindStates.Process();
if (!needsProcess)
return;
needsProcess = false;
if (!__KernelIsRunning()) {
ERROR_LOG(Log::SaveState, "Savestate failure: Unable to load without kernel, this should never happen.");
return;
}
std::vector<Operation> operations = Flush();
SaveStart state;
for (const auto &op : operations) {
// Re-check here, and not just in Enqueue: during boot, RetroAchievements is still
// identifying the game asynchronously, and until it's done hardcore mode doesn't
// read as active yet. An operation queued in that window (a hotkey press, --state,
// auto-load) passed the check in Enqueue and would otherwise be applied here, after
// identification has finished and hardcore mode has come up.
if (BannedInHardcoreMode(op.type)) {
WARN_LOG(Log::SaveState, "Dropping queued savestate operation - hardcore mode is active");
Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save);
continue;
}
CChunkFileReader::Error result;
Status callbackResult;
std::string callbackMessage;
std::string callbackMetadata;
std::string title;
auto sc = GetI18NCategory(I18NCat::SCREEN);
const char *i18nLoadFailure = sc->T_cstr("Failed to load state");
const char *i18nSaveFailure = sc->T_cstr("Failed to save state");
std::string slot_prefix = op.slot >= 0 ? StringFromFormat("(%d) ", op.slot + 1) : "";
std::string errorString;
switch (op.type) {
case OperationType::Load:
INFO_LOG(Log::SaveState, "Loading state from '%s'", op.path.c_str());
// Use the state's latest version as a guess for saveStateInitialGitVersion.
result = CChunkFileReader::Load(op.path, &saveStateInitialGitVersion, state, &errorString);
if (result == CChunkFileReader::ERROR_NONE) {
callbackMessage = op.slot != LOAD_UNDO_SLOT ? sc->T("Loaded State") : sc->T("State load undone");
callbackResult = TriggerLoadWarnings(callbackMessage);
callbackMetadata = SaveState::GetSaveFileDateAsString(op.path.GetFilename());
hasLoadedState = true;
Core_ResetException();
if (!slot_prefix.empty())
callbackMessage = slot_prefix + callbackMessage;
#ifndef MOBILE_DEVICE
if (g_Config.bSaveLoadResetsAVdumping) {
if (g_Config.bDumpFrames) {
AVIDump::Stop();
AVIDump::Start(PSP_CoreParameter().renderWidth, PSP_CoreParameter().renderHeight);
}
if (g_Config.bDumpAudio) {
WAVDump::Reset();
}
}
#endif
g_lastSaveTime = time_now_d();
} else if (result == CChunkFileReader::ERROR_BROKEN_STATE) {
HandleLoadFailure(false, &callbackMetadata);
callbackMessage = std::string(i18nLoadFailure) + ": " + errorString;
ERROR_LOG(Log::SaveState, "Load state failure: %s", errorString.c_str());
callbackResult = Status::FAILURE;
} else {
callbackMessage = sc->T(errorString, i18nLoadFailure);
callbackResult = Status::FAILURE;
}
break;
case OperationType::Save:
INFO_LOG(Log::SaveState, "Saving state to '%s'", op.path.c_str());
title = g_paramSFO.GetValueString("TITLE");
if (title.empty()) {
// Homebrew title
title = PSP_CoreParameter().fileToStart.ToVisualString();
std::size_t lslash = title.find_last_of('/');
title = title.substr(lslash + 1);
}
result = CChunkFileReader::Save(op.path, title, PPSSPP_GIT_VERSION, state);
if (result == CChunkFileReader::ERROR_NONE) {
callbackMessage = slot_prefix + std::string(sc->T("Saved State"));
callbackResult = Status::SUCCESS;
#ifndef MOBILE_DEVICE
if (g_Config.bSaveLoadResetsAVdumping) {
if (g_Config.bDumpFrames) {
AVIDump::Stop();
AVIDump::Start(PSP_CoreParameter().renderWidth, PSP_CoreParameter().renderHeight);
}
if (g_Config.bDumpAudio) {
WAVDump::Reset();
}
}
#endif
g_lastSaveTime = time_now_d();
} else if (result == CChunkFileReader::ERROR_BROKEN_STATE) {
// TODO: What else might we want to do here? This should be very unusual.
callbackMessage = i18nSaveFailure;
ERROR_LOG(Log::SaveState, "Save state failure");
callbackResult = Status::FAILURE;
} else {
callbackMessage = i18nSaveFailure;
callbackResult = Status::FAILURE;
}
break;
case OperationType::Verify:
{
int tempResult = CChunkFileReader::Verify(state) == CChunkFileReader::ERROR_NONE;
callbackResult = tempResult ? Status::SUCCESS : Status::FAILURE;
if (tempResult) {
INFO_LOG(Log::SaveState, "Verified save state system");
} else {
ERROR_LOG(Log::SaveState, "Save state system verification failed");
}
break;
}
case OperationType::Rewind:
INFO_LOG(Log::SaveState, "Rewinding to recent savestate snapshot");
result = rewindStates.Restore(&errorString, &callbackMetadata);
if (result == CChunkFileReader::ERROR_NONE) {
callbackMessage = sc->T("Loaded State");
callbackResult = Status::SUCCESS;
hasLoadedState = true;
Core_ResetException();
} else if (result == CChunkFileReader::ERROR_BROKEN_STATE) {
// Cripes. Good news is, we might have more. Let's try those too, better than a reset.
if (HandleLoadFailure(true, &callbackMetadata)) {
// Well, we did rewind, even if too much...
callbackMessage = sc->T("Loaded State");
callbackResult = Status::SUCCESS;
hasLoadedState = true;
Core_ResetException();
} else {
callbackMessage = std::string(i18nLoadFailure) + ": " + errorString;
callbackResult = Status::FAILURE;
}
} else {
callbackMessage = std::string(i18nLoadFailure) + ": " + errorString;
callbackResult = Status::FAILURE;
}
break;
default:
ERROR_LOG(Log::SaveState, "Savestate failure: unknown operation type %d", (int)op.type);
callbackResult = Status::FAILURE;
break;
}
if (op.callback) {
op.callback(callbackResult, callbackMessage, callbackMetadata);
}
}
if (operations.size()) {
// Avoid triggering frame skipping due to slowdown
__DisplaySetWasPaused();
}
}
void NotifySaveData() {
saveDataGeneration++;
lastSaveDataGeneration = saveDataGeneration;
}
bool PollRestartNeeded() {
if (needsRestart) {
needsRestart = false;
return true;
}
return false;
}
void Init() {
// Make sure there's a directory for save slots
File::CreateFullPath(GetSysDirectory(DIRECTORY_SAVESTATE));
std::lock_guard<std::mutex> guard(mutex);
rewindStates.Clear();
hasLoadedState = false;
saveStateGeneration = 0;
saveDataGeneration = 0;
lastSaveDataGeneration = 0;
saveStateInitialGitVersion.clear();
g_lastSaveTime = time_now_d();
}
void Shutdown() {
std::lock_guard<std::mutex> guard(mutex);
rewindStates.Clear();
}
double SecondsSinceLastSavestate() {
if (g_lastSaveTime < 0) {
return -1.0;
} else {
return time_now_d() - g_lastSaveTime;
}
}
} // namespace SaveState