mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-10-21 02:09:05 +02:00
- A failed final flush (a full disk) or a failed rename into place was reported as "Saved State". A failed load undo rename still pointed load undo at the current game, while the file was another game's state. - Refusing a save or load for being online didn't call the callback, unlike every other refusal. - A rewind dropped after its decode started stayed active, and stopped rewind snapshots until the next clear. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1505 lines
49 KiB
C++
1505 lines
49 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 <atomic>
|
|
#include <condition_variable>
|
|
#include <deque>
|
|
#include <map>
|
|
#include <memory>
|
|
#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/sceKernelModule.h"
|
|
#include "Core/HLE/sceUtility.h"
|
|
#include "Core/HLE/sceSas.h"
|
|
#include "Core/HLE/sceIo.h"
|
|
#include "Core/MemMap.h"
|
|
#include "GPU/GPU.h"
|
|
#include "GPU/GPUCommon.h"
|
|
#include "Core/MIPS/JitCommon/JitBlockCache.h"
|
|
#include "Core/MIPS/JitCommon/JitCommon.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";
|
|
// Namespaced the way UNDO_STATE_EXTENSION is, so a stray "<prefix>_<slot>.txt" someone happened to
|
|
// leave in the savestate folder isn't mistaken for a slot's name.
|
|
static const char * const NAME_EXTENSION = "name.txt";
|
|
|
|
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.
|
|
// Set when a slot save finishes. That's on the emu thread, so the rescan is left to the next reader.
|
|
static std::atomic<bool> g_filesStale;
|
|
|
|
enum class OperationType {
|
|
Save,
|
|
Load,
|
|
Verify,
|
|
Rewind,
|
|
};
|
|
|
|
// A load's file, read on the worker. Shared between the operation and the job.
|
|
struct PendingRead {
|
|
std::atomic<bool> done{ false };
|
|
CChunkFileReader::Error error = CChunkFileReader::ERROR_NONE;
|
|
std::unique_ptr<u8[]> buffer;
|
|
size_t size = 0;
|
|
std::string gitVersion;
|
|
std::string failureReason;
|
|
// "PPSSPP <version>" from the header, for the failure message.
|
|
std::string savedBy;
|
|
};
|
|
|
|
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;
|
|
// For slot operations, the game prefix. A slot save writes to a temporary file and moves the
|
|
// slot's old state to its undo name. A slot load keeps the state it replaces, for load undo.
|
|
std::string slotGamePrefix;
|
|
// Set once the operation has started: a load or rewind waits at the head of the queue until its
|
|
// worker part is done.
|
|
bool started = false;
|
|
std::shared_ptr<PendingRead> read;
|
|
};
|
|
|
|
// Front to back, each operation's emu thread part runs in turn. A load or rewind that's still
|
|
// waiting for the worker holds up the operations behind it.
|
|
static std::deque<Operation> g_pendingOperations;
|
|
|
|
// Saves asked for and not yet written, by path, with the callbacks of any repeats of them (a save
|
|
// key pressed again before the first save finished). Under mutex.
|
|
static std::map<std::string, std::vector<Callback>> g_pendingSaves;
|
|
// Save snapshots in memory, waiting to be compressed and written. Each is a whole state.
|
|
static std::atomic<int> g_saveSnapshots;
|
|
constexpr int MAX_SAVE_SNAPSHOTS = 2;
|
|
|
|
// The savestate worker. One thread, so jobs run in order: a slot's save is written before a later
|
|
// load of it reads the file.
|
|
static std::thread g_workerThread;
|
|
static std::mutex g_workerMutex;
|
|
static std::condition_variable g_workerCond;
|
|
static std::condition_variable g_workerIdleCond;
|
|
static std::deque<std::function<void()>> g_workerJobs;
|
|
static bool g_workerBusy = false;
|
|
static bool g_workerQuit = false;
|
|
|
|
// What's left of an operation once its worker job is done, to run on the emu thread from Process().
|
|
// The argument says whether to call the operation's callbacks: not at shutdown, where they can
|
|
// refer to an EmuScreen that's going away.
|
|
static std::mutex g_completionMutex;
|
|
static std::vector<std::function<void(bool)>> g_completions;
|
|
|
|
int g_screenshotFailures;
|
|
|
|
CChunkFileReader::Error SaveToRam(std::vector<u8> &data) {
|
|
SaveStart state;
|
|
state.countsAsSave = false;
|
|
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) {
|
|
// Nothing may still be writing PSP memory while it's saved, or be left to write into what's loaded.
|
|
__UtilityWaitForIO();
|
|
__SasWaitForMix();
|
|
__IoWaitForAsync();
|
|
if (gpu)
|
|
gpu->FlushPendingDrawing();
|
|
|
|
auto s = p.Section("SaveStart", 1, 3);
|
|
if (!s)
|
|
return;
|
|
|
|
if (s >= 2) {
|
|
// This only increments on save, of course (once, not also in the measuring pass.)
|
|
if (p.mode == p.MODE_WRITE && countsAsSave) {
|
|
++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);
|
|
}
|
|
|
|
// The state may hold different code. KernelModuleDoState hooks the functions again.
|
|
std::vector<std::pair<u32, u32>> hookedOps;
|
|
// The loaded modules' code is compared with the state's as it's loaded. Where it's the same,
|
|
// the JIT keeps its blocks, so they don't all have to be compiled again.
|
|
Memory::RamCompare codeCompare;
|
|
if (p.mode == p.MODE_READ) {
|
|
hookedOps = Replacement_GetHookedOps();
|
|
RestoreReplacedInstructions(0, 0xFFFFFFFF);
|
|
codeCompare.ranges = __KernelModuleCodeRanges();
|
|
}
|
|
Memory::DoState(p, &codeCompare);
|
|
if (p.mode == p.MODE_READ) {
|
|
Replacement_RepairStaleHookOps(hookedOps);
|
|
__KernelModuleSetUnchangedCodeOnLoad(codeCompare.unchanged);
|
|
currentMIPS->KeepJitBlocksOnNextLoad(std::move(codeCompare.unchanged));
|
|
}
|
|
|
|
if (s >= 3) {
|
|
CoreTiming::DoState(p);
|
|
}
|
|
|
|
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);
|
|
|
|
if (p.mode == p.MODE_READ && p.error == PointerWrap::ERROR_FAILURE) {
|
|
// RAM may be loaded and the JIT not told, or the other way round. Keep nothing.
|
|
currentMIPS->KeepJitBlocksOnNextLoad({});
|
|
currentMIPS->ClearJitCacheDeferred();
|
|
}
|
|
}
|
|
|
|
static void WorkerLoop() {
|
|
SetCurrentThreadName("SaveStateWorker");
|
|
// Savestates can be in Android's scoped storage, which goes through JNI.
|
|
AndroidJNIThreadContext jniContext;
|
|
|
|
std::unique_lock<std::mutex> lock(g_workerMutex);
|
|
while (true) {
|
|
g_workerCond.wait(lock, [] { return g_workerQuit || !g_workerJobs.empty(); });
|
|
// Asked to quit, finish what's queued first.
|
|
if (g_workerJobs.empty()) {
|
|
break;
|
|
}
|
|
std::function<void()> job = std::move(g_workerJobs.front());
|
|
g_workerJobs.pop_front();
|
|
g_workerBusy = true;
|
|
lock.unlock();
|
|
job();
|
|
lock.lock();
|
|
g_workerBusy = false;
|
|
if (g_workerJobs.empty()) {
|
|
g_workerIdleCond.notify_all();
|
|
}
|
|
}
|
|
}
|
|
|
|
void RunOnWorker(std::function<void()> job) {
|
|
std::lock_guard<std::mutex> guard(g_workerMutex);
|
|
if (!g_workerThread.joinable()) {
|
|
g_workerQuit = false;
|
|
g_workerThread = std::thread(&WorkerLoop);
|
|
}
|
|
g_workerJobs.push_back(std::move(job));
|
|
g_workerCond.notify_one();
|
|
}
|
|
|
|
void WaitForWorker() {
|
|
std::unique_lock<std::mutex> lock(g_workerMutex);
|
|
g_workerIdleCond.wait(lock, [] { return g_workerJobs.empty() && !g_workerBusy; });
|
|
}
|
|
|
|
static void StopWorker() {
|
|
{
|
|
std::lock_guard<std::mutex> guard(g_workerMutex);
|
|
g_workerQuit = true;
|
|
g_workerCond.notify_one();
|
|
}
|
|
if (g_workerThread.joinable()) {
|
|
g_workerThread.join();
|
|
}
|
|
}
|
|
|
|
// Shutdown() normally stops the worker. This is for an exit that never got there: destroying a
|
|
// joinable std::thread terminates the process.
|
|
static struct WorkerStopper {
|
|
~WorkerStopper() {
|
|
StopWorker();
|
|
}
|
|
} g_workerStopper;
|
|
|
|
static void PostCompletion(std::function<void(bool)> completion) {
|
|
std::lock_guard<std::mutex> guard(g_completionMutex);
|
|
g_completions.push_back(std::move(completion));
|
|
}
|
|
|
|
static void RunCompletions(bool callCallbacks) {
|
|
std::vector<std::function<void(bool)>> completions;
|
|
{
|
|
std::lock_guard<std::mutex> guard(g_completionMutex);
|
|
completions.swap(g_completions);
|
|
}
|
|
for (auto &completion : completions) {
|
|
completion(callCallbacks);
|
|
}
|
|
}
|
|
|
|
// For an operation that was refused after a warning was already shown, so there's no message.
|
|
// The callback still has to hear about it (auto-load tracks failure that way).
|
|
static void CallbackRefused(const Callback &callback) {
|
|
if (callback) {
|
|
callback(Status::FAILURE, "", "");
|
|
}
|
|
}
|
|
|
|
static bool IsSavePending(const Path &path) {
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
return g_pendingSaves.find(path.ToString()) != g_pendingSaves.end();
|
|
}
|
|
|
|
// If a save to path is already pending, this is a repeat of it (a key pressed twice): the callback
|
|
// hears that save's result, with no message so it's only shown once. Returns true if so.
|
|
static bool AttachToPendingSave(const Path &path, const Callback &callback) {
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
auto iter = g_pendingSaves.find(path.ToString());
|
|
if (iter == g_pendingSaves.end()) {
|
|
return false;
|
|
}
|
|
if (callback) {
|
|
iter->second.push_back(callback);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Takes a save out of g_pendingSaves, returning the callbacks of its repeats.
|
|
static std::vector<Callback> TakePendingSave(const Path &path) {
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
std::vector<Callback> repeats;
|
|
auto iter = g_pendingSaves.find(path.ToString());
|
|
if (iter != g_pendingSaves.end()) {
|
|
repeats = std::move(iter->second);
|
|
g_pendingSaves.erase(iter);
|
|
}
|
|
return repeats;
|
|
}
|
|
|
|
static void RefuseOperation(const Operation &op) {
|
|
CallbackRefused(op.callback);
|
|
if (op.type == OperationType::Save) {
|
|
for (const Callback &repeat : TakePendingSave(op.path)) {
|
|
CallbackRefused(repeat);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool Enqueue(const SaveState::Operation &op) {
|
|
// These two both refuse the operation, and both say so - dropping it silently just
|
|
// looks like the hotkey didn't work. Callers that ask the same questions themselves
|
|
// return before getting here, so nothing shows a message twice.
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
CallbackRefused(op.callback);
|
|
return false;
|
|
}
|
|
// Hardcore mode bans loading savestates outright, and saving too unless the user opted
|
|
// back into that.
|
|
if (Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save)) {
|
|
CallbackRefused(op.callback);
|
|
return false;
|
|
}
|
|
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
if (op.type == OperationType::Save) {
|
|
g_pendingSaves.emplace(op.path.ToString(), std::vector<Callback>());
|
|
}
|
|
g_pendingOperations.push_back(op);
|
|
|
|
// Don't actually run it until next frame.
|
|
needsProcess = true;
|
|
return true;
|
|
}
|
|
|
|
static bool QueueLoad(const Operation &op) {
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
CallbackRefused(op.callback);
|
|
return false;
|
|
}
|
|
|
|
rewindStates.NotifyState();
|
|
if (coreState == CoreState::CORE_RUNTIME_ERROR)
|
|
Core_Break(BreakReason::SavestateLoad, 0);
|
|
return Enqueue(op);
|
|
}
|
|
|
|
static bool QueueSave(const Operation &op) {
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
CallbackRefused(op.callback);
|
|
return false;
|
|
}
|
|
if (AttachToPendingSave(op.path, op.callback)) {
|
|
return true;
|
|
}
|
|
|
|
rewindStates.NotifyState();
|
|
if (coreState == CoreState::CORE_RUNTIME_ERROR)
|
|
Core_Break(BreakReason::SavestateSave, 0);
|
|
return Enqueue(op);
|
|
}
|
|
|
|
bool Load(const Path &filename, int slot, Callback callback) {
|
|
return QueueLoad(Operation(OperationType::Load, filename, slot, callback));
|
|
}
|
|
|
|
bool Save(const Path &filename, int slot, Callback callback) {
|
|
return QueueSave(Operation(OperationType::Save, filename, slot, callback));
|
|
}
|
|
|
|
bool Verify(Callback callback) {
|
|
return Enqueue(Operation(OperationType::Verify, Path(), -1, callback));
|
|
}
|
|
|
|
bool Rewind(Callback callback) {
|
|
// Or a snapshot can be taken just before the rewind runs (after a long pause, say), and the
|
|
// rewind then restores that.
|
|
rewindStates.NotifyState();
|
|
if (coreState == CoreState::CORE_RUNTIME_ERROR)
|
|
Core_Break(BreakReason::SavestateRewind, 0);
|
|
return Enqueue(Operation(OperationType::Rewind, Path(), -1, callback));
|
|
}
|
|
|
|
bool CanRewind() {
|
|
return !rewindStates.Empty();
|
|
}
|
|
|
|
bool IsBusy() {
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
// A save leaves the queue once its snapshot is taken, but stays pending until it's written.
|
|
return !g_pendingOperations.empty() || !g_pendingSaves.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 ¶mSFO) {
|
|
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 ¶mSFO) {
|
|
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, bool retry = false) {
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
if (!retry) {
|
|
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, true);
|
|
}
|
|
break;
|
|
case ScreenshotResult::FailedToWriteFile:
|
|
case ScreenshotResult::Success:
|
|
g_screenshotFailures = 0;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
void LoadSlot(std::string_view gamePrefix, int slot, Callback callback) {
|
|
// Enqueue() would refuse too, but only after the load undo backup below has replaced the old one.
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate() || Achievements::WarnUserIfHardcoreModeActive(false)) {
|
|
CallbackRefused(callback);
|
|
return;
|
|
}
|
|
|
|
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
|
|
// A save that hasn't been written yet will be by the time the load reads the file.
|
|
if (!fn.empty() && !File::Exists(fn) && !IsSavePending(fn)) {
|
|
if (callback) {
|
|
auto sc = GetI18NCategory(I18NCat::SCREEN);
|
|
callback(Status::FAILURE, sc->T("LoadStateDoesntExist", sc->T("Failed to load state")), "");
|
|
}
|
|
return;
|
|
}
|
|
if (!fn.empty()) {
|
|
// The state it replaces is kept for load undo, when the load is applied.
|
|
Operation op(OperationType::Load, fn, slot, callback);
|
|
op.slotGamePrefix = gamePrefix;
|
|
QueueLoad(op);
|
|
} 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 (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
CallbackRefused(callback);
|
|
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) {
|
|
// Enqueue() would refuse too, but only after the screenshots below have been rotated.
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate() || Achievements::WarnUserIfHardcoreModeActive(true)) {
|
|
CallbackRefused(callback);
|
|
return;
|
|
}
|
|
Path fn = GenerateSaveSlotPath(gamePrefix, slot, STATE_EXTENSION);
|
|
if (!fn.empty()) {
|
|
// Before the screenshots below are touched: a repeat shouldn't rotate them again.
|
|
if (AttachToPendingSave(fn, callback)) {
|
|
return;
|
|
}
|
|
Path shot = GenerateSaveSlotPath(gamePrefix, slot, SCREENSHOT_EXTENSION);
|
|
// 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);
|
|
// Written to a temporary file, then moved into place with the old state kept for undo.
|
|
Operation op(OperationType::Save, fn, slot, callback);
|
|
op.slotGamePrefix = gamePrefix;
|
|
QueueSave(op);
|
|
} 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 (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
return false;
|
|
}
|
|
|
|
Path fnUndo = GenerateSaveSlotPath(gamePrefix, slot, UNDO_STATE_EXTENSION);
|
|
// A save still being written would move files around under us.
|
|
WaitForWorker();
|
|
|
|
// 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);
|
|
Rescan(gamePrefix);
|
|
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);
|
|
Path fnName = GenerateSaveSlotPath(gamePrefix, slot, NAME_EXTENSION);
|
|
// Or a save still being written would recreate the file.
|
|
WaitForWorker();
|
|
|
|
if (File::Exists(fn)) {
|
|
DeleteIfExists(fn);
|
|
DeleteIfExists(shot);
|
|
DeleteIfExists(fnName);
|
|
}
|
|
Rescan(gamePrefix);
|
|
}
|
|
|
|
bool UndoLastSave(std::string_view gamePrefix) {
|
|
if (NetworkWarnUserIfOnlineAndCantSavestate()) {
|
|
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 void RescanIfStale(std::string_view gamePrefix) {
|
|
if (g_filesStale.exchange(false)) {
|
|
Rescan(gamePrefix);
|
|
}
|
|
}
|
|
|
|
static bool SaveStateFileExists(std::string_view gamePrefix, int slot, const char *extension) {
|
|
RescanIfStale(gamePrefix);
|
|
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) {
|
|
RescanIfStale(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) {
|
|
RescanIfStale(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::string GetSlotCustomName(std::string_view gamePrefix, int slot) {
|
|
// Most slots don't have a name file, and this gets called for every slot each time the
|
|
// pause screen builds its views - so consult the listing before touching the disk.
|
|
if (!SaveStateFileExists(gamePrefix, slot, NAME_EXTENSION)) {
|
|
return std::string();
|
|
}
|
|
Path path = GenerateSaveSlotPath(gamePrefix, slot, NAME_EXTENSION);
|
|
std::string result;
|
|
File::ReadBinaryFileToString(path, &result);
|
|
return result;
|
|
}
|
|
|
|
void SetSlotCustomName(std::string_view gamePrefix, int slot, std::string_view new_name){
|
|
const Path path = GenerateSaveSlotPath(gamePrefix, slot, NAME_EXTENSION);
|
|
if (new_name.empty()) {
|
|
// Clearing the name. Leaving an empty file behind would work, but it'd stay in the
|
|
// savestate folder for good - a slot with no name shouldn't have a name file.
|
|
DeleteIfExists(path);
|
|
} else {
|
|
File::WriteStringToFile(true, new_name, path);
|
|
}
|
|
// What we just wrote (or removed) isn't reflected in the listing GetSlotCustomName reads,
|
|
// so without this the change wouldn't show up until something else happened to rescan.
|
|
Rescan(gamePrefix);
|
|
}
|
|
|
|
std::vector<Path> GetCompanionFilePaths(const Path &statePath) {
|
|
const std::string stateExtension = std::string(".") + STATE_EXTENSION;
|
|
// A path that isn't a savestate yields nothing, which WithReplacedExtension now tells us
|
|
// rather than handing back the path itself. An undo state does work, and correctly:
|
|
// "x.undo.ppst" maps onto "x.undo.jpg".
|
|
std::vector<Path> paths;
|
|
for (const char *extension : { SCREENSHOT_EXTENSION, NAME_EXTENSION }) {
|
|
Path companion;
|
|
if (statePath.WithReplacedExtension(stateExtension, std::string(".") + extension, &companion)) {
|
|
paths.push_back(companion);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
// Which build last saved the file, from its header. Shown when a load fails, since a state from
|
|
// another version is the usual suspect.
|
|
static std::string SavedByVersion(const Path &path) {
|
|
std::string title;
|
|
std::string gitVersion;
|
|
if (CChunkFileReader::GetFileTitle(path, &title, &gitVersion) != CChunkFileReader::ERROR_NONE || gitVersion.empty()) {
|
|
return "";
|
|
}
|
|
return "PPSSPP " + gitVersion;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
static std::string SlotPrefix(int slot) {
|
|
return slot >= 0 ? StringFromFormat("(%d) ", slot + 1) : "";
|
|
}
|
|
|
|
static void ResetAVDumping() {
|
|
#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
|
|
}
|
|
|
|
// What a save's worker job needs, and its completion after.
|
|
struct SaveJob {
|
|
Path path;
|
|
// Where the file is written: path, or for a slot save, a temporary file moved into place after.
|
|
Path writePath;
|
|
std::string slotGamePrefix;
|
|
int slot;
|
|
// For a slot save: whether the old state was kept as the slot's undo, and where.
|
|
bool keepUndo = false;
|
|
Path undoPath;
|
|
Callback callback;
|
|
};
|
|
|
|
// Emu thread, once the file is written (or failed to be).
|
|
static void FinishSave(const SaveJob &job, CChunkFileReader::Error result, bool callCallbacks) {
|
|
auto sc = GetI18NCategory(I18NCat::SCREEN);
|
|
Status status = Status::SUCCESS;
|
|
std::string message;
|
|
if (result == CChunkFileReader::ERROR_NONE) {
|
|
message = SlotPrefix(job.slot) + std::string(sc->T("Saved State"));
|
|
g_lastSaveTime = time_now_d();
|
|
if (!job.slotGamePrefix.empty()) {
|
|
if (job.keepUndo) {
|
|
g_Config.sStateUndoLastSaveGame = job.slotGamePrefix;
|
|
g_Config.iStateUndoLastSaveSlot = job.slot;
|
|
g_Config.Save("Saving config for savestate last save undo");
|
|
}
|
|
g_filesStale = true;
|
|
}
|
|
} else {
|
|
if (result == CChunkFileReader::ERROR_BROKEN_STATE) {
|
|
// TODO: What else might we want to do here? This should be very unusual.
|
|
ERROR_LOG(Log::SaveState, "Save state failure");
|
|
}
|
|
message = sc->T("Failed to save state");
|
|
status = Status::FAILURE;
|
|
}
|
|
|
|
std::vector<Callback> repeats = TakePendingSave(job.path);
|
|
if (!callCallbacks) {
|
|
return;
|
|
}
|
|
if (job.callback) {
|
|
job.callback(status, message, "");
|
|
}
|
|
for (const Callback &repeat : repeats) {
|
|
repeat(status, "", "");
|
|
}
|
|
}
|
|
|
|
// Returns false if it has to wait for an earlier save to be written first.
|
|
static bool RunSave(const Operation &op) {
|
|
if (g_saveSnapshots >= MAX_SAVE_SNAPSHOTS) {
|
|
return false;
|
|
}
|
|
|
|
INFO_LOG(Log::SaveState, "Saving state to '%s'", op.path.c_str());
|
|
SaveJob job;
|
|
job.path = op.path;
|
|
job.writePath = op.slotGamePrefix.empty() ? op.path : op.path.WithExtraExtension(".tmp");
|
|
job.slotGamePrefix = op.slotGamePrefix;
|
|
job.slot = op.slot;
|
|
job.callback = op.callback;
|
|
if (!op.slotGamePrefix.empty()) {
|
|
job.keepUndo = g_Config.bEnableStateUndo;
|
|
job.undoPath = GenerateSaveSlotPath(op.slotGamePrefix, op.slot, UNDO_STATE_EXTENSION);
|
|
}
|
|
|
|
std::string 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);
|
|
}
|
|
|
|
u8 *buffer = nullptr;
|
|
size_t size = 0;
|
|
SaveStart state;
|
|
CChunkFileReader::Error result = CChunkFileReader::MeasureAndSavePtr(state, &buffer, &size);
|
|
if (result != CChunkFileReader::ERROR_NONE) {
|
|
FinishSave(job, result, true);
|
|
return true;
|
|
}
|
|
ResetAVDumping();
|
|
|
|
g_saveSnapshots++;
|
|
RunOnWorker([job, title, buffer, size]() {
|
|
CChunkFileReader::Error result = CChunkFileReader::SaveFile(job.writePath, title, PPSSPP_GIT_VERSION, buffer, size);
|
|
g_saveSnapshots--;
|
|
if (result == CChunkFileReader::ERROR_NONE && !job.slotGamePrefix.empty()) {
|
|
if (job.keepUndo) {
|
|
DeleteIfExists(job.undoPath);
|
|
RenameIfExists(job.path, job.undoPath);
|
|
} else {
|
|
DeleteIfExists(job.path);
|
|
}
|
|
if (!File::Rename(job.writePath, job.path)) {
|
|
ERROR_LOG(Log::SaveState, "Failed to move '%s' into place", job.writePath.c_str());
|
|
result = CChunkFileReader::ERROR_BAD_FILE;
|
|
}
|
|
}
|
|
PostCompletion([job, result](bool callCallbacks) {
|
|
FinishSave(job, result, callCallbacks);
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Keeps the state a slot load is about to replace, as load undo. Snapshotted here, compressed and
|
|
// written on the worker.
|
|
static void SaveLoadUndo(const std::string &gamePrefix) {
|
|
Path backup = GetSysDirectory(DIRECTORY_SAVESTATE) / LOAD_UNDO_NAME;
|
|
if (backup.empty()) {
|
|
ERROR_LOG(Log::SaveState, "Saving load undo state failed. Error in the file system.");
|
|
return;
|
|
}
|
|
|
|
u8 *buffer = nullptr;
|
|
size_t size = 0;
|
|
SaveStart state;
|
|
if (CChunkFileReader::MeasureAndSavePtr(state, &buffer, &size) != CChunkFileReader::ERROR_NONE) {
|
|
ERROR_LOG(Log::SaveState, "Saving load undo state failed");
|
|
return;
|
|
}
|
|
|
|
std::string title = g_paramSFO.GetValueString("TITLE");
|
|
g_saveSnapshots++;
|
|
RunOnWorker([backup, title, buffer, size, gamePrefix]() {
|
|
Path temp = backup.WithExtraExtension(".tmp");
|
|
CChunkFileReader::Error result = CChunkFileReader::SaveFile(temp, title, PPSSPP_GIT_VERSION, buffer, size);
|
|
g_saveSnapshots--;
|
|
if (result == CChunkFileReader::ERROR_NONE) {
|
|
DeleteIfExists(backup);
|
|
if (!File::Rename(temp, backup)) {
|
|
// The undo would load whatever was there before, maybe another game's state.
|
|
result = CChunkFileReader::ERROR_BAD_FILE;
|
|
}
|
|
}
|
|
// Not a callback, so it runs at shutdown too.
|
|
PostCompletion([result, gamePrefix](bool) {
|
|
if (result == CChunkFileReader::ERROR_NONE) {
|
|
g_Config.sStateLoadUndoGame = gamePrefix;
|
|
g_Config.Save("Saving config for savestate last load undo");
|
|
} else {
|
|
ERROR_LOG(Log::SaveState, "Saving load undo state failed");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Returns false while the file is still being read.
|
|
static bool RunLoad(Operation &op) {
|
|
if (!op.read) {
|
|
INFO_LOG(Log::SaveState, "Loading state from '%s'", op.path.c_str());
|
|
op.read = std::make_shared<PendingRead>();
|
|
RunOnWorker([read = op.read, path = op.path]() {
|
|
u8 *buffer = nullptr;
|
|
read->error = CChunkFileReader::LoadFile(path, &read->gitVersion, buffer, read->size, &read->failureReason);
|
|
read->buffer.reset(buffer);
|
|
if (read->error == CChunkFileReader::ERROR_NONE) {
|
|
read->savedBy = read->gitVersion.empty() ? "" : "PPSSPP " + read->gitVersion;
|
|
} else {
|
|
read->savedBy = SavedByVersion(path);
|
|
}
|
|
read->done = true;
|
|
});
|
|
return false;
|
|
}
|
|
if (!op.read->done) {
|
|
return false;
|
|
}
|
|
|
|
PendingRead &read = *op.read;
|
|
auto sc = GetI18NCategory(I18NCat::SCREEN);
|
|
const char *i18nLoadFailure = sc->T_cstr("Failed to load state");
|
|
Status status;
|
|
std::string message;
|
|
std::string metadata;
|
|
|
|
if (read.error != CChunkFileReader::ERROR_NONE) {
|
|
message = read.failureReason.empty() ? std::string(i18nLoadFailure) : std::string(sc->T(read.failureReason, i18nLoadFailure));
|
|
metadata = read.savedBy;
|
|
if (op.callback) {
|
|
op.callback(Status::FAILURE, message, metadata);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Kept even if the load fails: that resets the game, and this is the way back.
|
|
if (!op.slotGamePrefix.empty() && g_Config.bEnableStateUndo) {
|
|
SaveLoadUndo(op.slotGamePrefix);
|
|
}
|
|
|
|
// Use the state's latest version as a guess for saveStateInitialGitVersion.
|
|
saveStateInitialGitVersion = read.gitVersion;
|
|
SaveStart state;
|
|
std::string errorString;
|
|
CChunkFileReader::Error result = CChunkFileReader::LoadPtr(read.buffer.get(), read.size, state, &errorString);
|
|
read.buffer.reset();
|
|
|
|
if (result == CChunkFileReader::ERROR_NONE) {
|
|
message = op.slot != LOAD_UNDO_SLOT ? sc->T("Loaded State") : sc->T("State load undone");
|
|
status = TriggerLoadWarnings(message);
|
|
metadata = SaveState::GetSaveFileDateAsString(op.path.GetFilename());
|
|
|
|
hasLoadedState = true;
|
|
Core_ResetException();
|
|
|
|
if (op.slot >= 0)
|
|
message = SlotPrefix(op.slot) + message;
|
|
|
|
ResetAVDumping();
|
|
g_lastSaveTime = time_now_d();
|
|
} else {
|
|
HandleLoadFailure(false, &metadata);
|
|
// The file was fine, its contents weren't: almost always a state from another version.
|
|
// errorString is the section that failed.
|
|
message = sc->T("LoadStateWrongVersion", i18nLoadFailure);
|
|
metadata = read.savedBy.empty() ? "(" + errorString + ")" : read.savedBy + " (" + errorString + ")";
|
|
ERROR_LOG(Log::SaveState, "Load state failure at %s (saved by %s)", errorString.c_str(), read.savedBy.c_str());
|
|
status = Status::FAILURE;
|
|
}
|
|
if (op.callback) {
|
|
op.callback(status, message, metadata);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Returns false while the snapshot is still being decoded.
|
|
static bool RunRewind(Operation &op) {
|
|
auto sc = GetI18NCategory(I18NCat::SCREEN);
|
|
const char *i18nLoadFailure = sc->T_cstr("Failed to load state");
|
|
if (!op.started) {
|
|
INFO_LOG(Log::SaveState, "Rewinding to recent savestate snapshot");
|
|
op.started = true;
|
|
if (rewindStates.StartRestore()) {
|
|
return false;
|
|
}
|
|
if (op.callback) {
|
|
op.callback(Status::FAILURE, i18nLoadFailure, "");
|
|
}
|
|
return true;
|
|
}
|
|
if (!rewindStates.RestoreDecoded()) {
|
|
return false;
|
|
}
|
|
|
|
Status status;
|
|
std::string message;
|
|
std::string metadata;
|
|
std::string errorString;
|
|
CChunkFileReader::Error result = rewindStates.FinishRestore(&errorString, &metadata);
|
|
if (result == CChunkFileReader::ERROR_NONE) {
|
|
message = sc->T("Loaded State");
|
|
status = 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, &metadata)) {
|
|
// Well, we did rewind, even if too much...
|
|
message = sc->T("Loaded State");
|
|
status = Status::SUCCESS;
|
|
hasLoadedState = true;
|
|
Core_ResetException();
|
|
} else {
|
|
message = std::string(i18nLoadFailure) + " (" + errorString + ")";
|
|
status = Status::FAILURE;
|
|
}
|
|
} else {
|
|
message = i18nLoadFailure;
|
|
status = Status::FAILURE;
|
|
}
|
|
if (op.callback) {
|
|
op.callback(status, message, metadata);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void RunVerify(const Operation &op) {
|
|
SaveStart verifyState;
|
|
verifyState.countsAsSave = false;
|
|
bool success = CChunkFileReader::Verify(verifyState) == CChunkFileReader::ERROR_NONE;
|
|
if (success) {
|
|
INFO_LOG(Log::SaveState, "Verified save state system");
|
|
} else {
|
|
ERROR_LOG(Log::SaveState, "Save state system verification failed");
|
|
}
|
|
if (op.callback) {
|
|
op.callback(success ? Status::SUCCESS : Status::FAILURE, "", "");
|
|
}
|
|
}
|
|
|
|
// The emu thread part of an operation. Returns false if it has to wait, keeping it (and those
|
|
// behind it) queued for a later Process().
|
|
static bool RunOperation(Operation &op) {
|
|
if (!op.started && !op.read) {
|
|
// 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 (Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save)) {
|
|
WARN_LOG(Log::SaveState, "Dropping queued savestate operation - hardcore mode is active");
|
|
RefuseOperation(op);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
switch (op.type) {
|
|
case OperationType::Save:
|
|
return RunSave(op);
|
|
case OperationType::Load:
|
|
return RunLoad(op);
|
|
case OperationType::Rewind:
|
|
return RunRewind(op);
|
|
case OperationType::Verify:
|
|
RunVerify(op);
|
|
return true;
|
|
default:
|
|
ERROR_LOG(Log::SaveState, "Savestate failure: unknown operation type %d", (int)op.type);
|
|
RefuseOperation(op);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// NOTE: This can cause ending of the current renderpass, due to the readback needed for the screenshot.
|
|
void Process() {
|
|
rewindStates.Process();
|
|
RunCompletions(true);
|
|
|
|
if (!needsProcess)
|
|
return;
|
|
if (coreState == CORE_STEPPING_GE || coreState == CORE_RUNNING_GE) {
|
|
// A display list stopped in the GE debugger still belongs to the sceGe call that started
|
|
// it, which finishes when the list does. Wait for that.
|
|
return;
|
|
}
|
|
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
if (!__KernelIsRunning()) {
|
|
ERROR_LOG(Log::SaveState, "Savestate failure: Unable to load without kernel, this should never happen.");
|
|
std::deque<Operation> operations;
|
|
operations.swap(g_pendingOperations);
|
|
needsProcess = false;
|
|
lock.unlock();
|
|
for (const Operation &op : operations) {
|
|
if (op.type == OperationType::Rewind && op.started) {
|
|
rewindStates.CancelRestore();
|
|
}
|
|
RefuseOperation(op);
|
|
}
|
|
return;
|
|
}
|
|
|
|
bool ranAny = false;
|
|
while (!g_pendingOperations.empty()) {
|
|
// Enqueue() only appends, which leaves this reference alone. Unlocked, since callbacks can
|
|
// queue more.
|
|
Operation &op = g_pendingOperations.front();
|
|
lock.unlock();
|
|
const bool finished = RunOperation(op);
|
|
lock.lock();
|
|
if (!finished) {
|
|
break;
|
|
}
|
|
ranAny = true;
|
|
g_pendingOperations.pop_front();
|
|
}
|
|
needsProcess = !g_pendingOperations.empty();
|
|
lock.unlock();
|
|
|
|
if (ranAny) {
|
|
// 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() {
|
|
// A save that was asked for still gets written. Then the bookkeeping that follows a write
|
|
// (the undo slot in the config, for one), but not the callbacks.
|
|
WaitForWorker();
|
|
RunCompletions(false);
|
|
|
|
std::lock_guard<std::mutex> guard(mutex);
|
|
rewindStates.Clear();
|
|
// Operations still queued belong to the game that's going away (Process() doesn't run while
|
|
// achievements block execution at boot). Run later, they'd load its state into the next game,
|
|
// and their callbacks can capture the EmuScreen that queued them.
|
|
if (!g_pendingOperations.empty()) {
|
|
WARN_LOG(Log::SaveState, "Dropping %d queued savestate operations at shutdown", (int)g_pendingOperations.size());
|
|
g_pendingOperations.clear();
|
|
}
|
|
g_pendingSaves.clear();
|
|
needsProcess = false;
|
|
StopWorker();
|
|
}
|
|
|
|
double SecondsSinceLastSavestate() {
|
|
if (g_lastSaveTime < 0) {
|
|
return -1.0;
|
|
} else {
|
|
return time_now_d() - g_lastSaveTime;
|
|
}
|
|
}
|
|
|
|
} // namespace SaveState
|