Debugger: Add wait to game.state.load/save

With wait: true, respond once the savestate operation has finished (with its
message), or with an error if it failed or was dropped without running.
Otherwise a client only learns the operation was queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-19 10:10:25 +02:00
1 parent 681a60b3ca
commit 4bfeb59fcd
3 files changed
+118 -75

No files matched your search

+117 -72
View File
@@ -29,6 +29,9 @@
#include <unistd.h>
#endif
#include <memory>
#include <mutex>
#include "Common/System/System.h"
#include "Core/Config.h"
#include "Core/Core.h"
@@ -46,6 +49,116 @@
// DebuggerParamType::OPTIONAL, exactly the problem the note above is avoiding.
bool NetworkAllowSpeedControl();
namespace {
// Filled in by the savestate callback, on the emu thread.
struct SaveStateResult {
std::mutex lock;
bool done = false;
SaveState::Status status = SaveState::Status::FAILURE;
std::string message;
};
struct WebSocketGameState : public DebuggerSubscriber {
void StateLoadSave(DebuggerRequest &req, bool save);
void Broadcast(net::WebSocketServer *ws) override;
private:
struct Waiting {
const char *event;
std::string ticket;
std::shared_ptr<SaveStateResult> result;
};
std::vector<Waiting> waiting_;
};
} // namespace
// Load a savestate file (game.state.load), or save one (game.state.save)
//
// The operation is queued, and happens at the end of the next frame the CPU runs, so follow this
// with something that runs it, like cpu.runUntilTime.
//
// Parameters:
// - path: string, path to the .ppst file.
// - wait: optional boolean, respond when the operation has finished rather than when queued.
//
// Response (same event name) once queued, or an error if the network or achievements hardcore
// mode refuses it. With wait, it's sent once done instead, with a message string, or an error
// if the load or save failed.
void WebSocketGameState::StateLoadSave(DebuggerRequest &req, bool save) {
const char *event = save ? "game.state.save" : "game.state.load";
std::string path;
bool wait = false;
if (!req.ParamString("path", &path) || !req.ParamBool("wait", &wait, DebuggerParamType::OPTIONAL))
return;
// The queued operation holds the only other reference, so Broadcast() can also tell when it's
// dropped without ever running (at shutdown, for one).
std::shared_ptr<SaveStateResult> result;
if (wait)
result = std::make_shared<SaveStateResult>();
auto callback = [event, result](SaveState::Status status, std::string_view message, std::string_view) {
if (status == SaveState::Status::FAILURE) {
ERROR_LOG(Log::System, "%s: %.*s", event, STR_VIEW(message));
}
if (result) {
std::lock_guard<std::mutex> guard(result->lock);
result->status = status;
result->message = message;
result->done = true;
}
};
bool running = false;
bool queued = false;
Core_RunOnCPUThread([&] {
running = PSP_GetBootState() == BootState::Complete;
if (running) {
queued = save ? SaveState::Save(Path(path), -1, callback) : SaveState::Load(Path(path), -1, callback);
}
});
if (!running)
return req.Fail("Game not running");
if (!queued) {
return req.Fail(save ? "Could not queue savestate save" : "Could not queue savestate load");
}
if (!wait) {
req.Respond();
return;
}
const JsonNode *ticket = req.data.get("ticket");
waiting_.push_back({ event, ticket ? json_stringify(ticket) : "", result });
}
void WebSocketGameState::Broadcast(net::WebSocketServer *ws) {
for (auto it = waiting_.begin(); it != waiting_.end(); ) {
// Check this first: if the callback ran, it was done before letting go.
bool dropped = it->result.use_count() == 1;
std::lock_guard<std::mutex> guard(it->result->lock);
if (!it->result->done && !dropped) {
++it;
continue;
}
bool failed = !it->result->done || it->result->status == SaveState::Status::FAILURE;
JsonWriter j;
j.begin();
j.writeString("event", failed ? "error" : it->event);
j.writeString("message", it->result->done ? it->result->message : "Savestate operation was dropped");
if (failed) {
j.writeInt("level", (int)LogLevel::LERROR);
}
if (!it->ticket.empty()) {
j.writeRaw("ticket", it->ticket);
}
j.end();
ws->Send(j.str());
it = waiting_.erase(it);
}
}
static uint32_t GetOwnProcessID() {
#if PPSSPP_PLATFORM(UWP)
return (uint32_t)GetCurrentProcessId();
@@ -57,15 +170,16 @@ static uint32_t GetOwnProcessID() {
}
DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map) {
auto p = new WebSocketGameState();
map["game.reset"] = &WebSocketGameReset;
map["game.status"] = &WebSocketGameStatus;
map["game.state.load"] = &WebSocketGameStateLoad;
map["game.state.save"] = &WebSocketGameStateSave;
map["game.state.load"] = [p](DebuggerRequest &req) { p->StateLoadSave(req, false); };
map["game.state.save"] = [p](DebuggerRequest &req) { p->StateLoadSave(req, true); };
map["game.speed.get"] = &WebSocketGameSpeedGet;
map["game.speed.set"] = &WebSocketGameSpeedSet;
map["version"] = &WebSocketVersion;
return nullptr;
return p;
}
// Reset emulation (game.reset)
@@ -128,75 +242,6 @@ void WebSocketGameStatus(DebuggerRequest &req) {
});
}
// Load a savestate file (game.state.load)
//
// The load is queued, and happens at the end of the next frame the CPU runs, so follow this with
// something that runs it, like cpu.runUntilTime.
//
// Parameters:
// - path: string, path to the .ppst file.
//
// Response (same event name) with no extra data, once queued, or an error if the network or
// achievements hardcore mode refuses it.
void WebSocketGameStateLoad(DebuggerRequest &req) {
std::string path;
if (!req.ParamString("path", &path))
return;
bool running = false;
bool queued = false;
Core_RunOnCPUThread([&] {
running = PSP_GetBootState() == BootState::Complete;
if (running) {
queued = SaveState::Load(Path(path), -1, [](SaveState::Status status, std::string_view message, std::string_view) {
if (status == SaveState::Status::FAILURE) {
ERROR_LOG(Log::System, "game.state.load: %.*s", STR_VIEW(message));
}
});
}
});
if (!running)
return req.Fail("Game not running");
if (!queued) {
return req.Fail("Could not queue savestate load");
}
req.Respond();
}
// Save a savestate file (game.state.save)
//
// Like game.state.load, the save is queued and happens at the end of the next frame the CPU runs.
//
// Parameters:
// - path: string, path to write the .ppst file to.
//
// Response (same event name) with no extra data, once queued, or an error if the network or
// achievements hardcore mode refuses it.
void WebSocketGameStateSave(DebuggerRequest &req) {
std::string path;
if (!req.ParamString("path", &path))
return;
bool running = false;
bool queued = false;
Core_RunOnCPUThread([&] {
running = PSP_GetBootState() == BootState::Complete;
if (running) {
queued = SaveState::Save(Path(path), -1, [](SaveState::Status status, std::string_view message, std::string_view) {
if (status == SaveState::Status::FAILURE) {
ERROR_LOG(Log::System, "game.state.save: %.*s", STR_VIEW(message));
}
});
}
});
if (!running)
return req.Fail("Game not running");
if (!queued) {
return req.Fail("Could not queue savestate save");
}
req.Respond();
}
// Percentages are relative to 60 FPS, matching how the in-app settings present the alternative
// speeds (see GameSettingsScreen.cpp) - so a client and the UI agree on what "200%" means.
static int PercentToFps(int percent) {
-2
View File
@@ -23,8 +23,6 @@ DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map);
void WebSocketGameReset(DebuggerRequest &req);
void WebSocketGameStatus(DebuggerRequest &req);
void WebSocketGameStateLoad(DebuggerRequest &req);
void WebSocketGameStateSave(DebuggerRequest &req);
void WebSocketGameSpeedGet(DebuggerRequest &req);
void WebSocketGameSpeedSet(DebuggerRequest &req);
void WebSocketVersion(DebuggerRequest &req);
+1 -1
View File
@@ -271,7 +271,7 @@ file - this is just an index.
| Category | Events | File |
|---|---|---|
| Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file, applied at the end of the next frame the CPU runs), `game.state.save` (the same for saving one), `game.speed.get/set` (emulation speed - unlimited fast-forward, or a percentage of 60 FPS; see below), `version` | `GameSubscriber.cpp` |
| Game/version | `game.reset`, `game.status`, `game.state.load` (queue loading a `.ppst` file, applied at the end of the next frame the CPU runs; with `wait: true` the response comes once it's done, or as an error if it failed), `game.state.save` (the same for saving one), `game.speed.get/set` (emulation speed - unlimited fast-forward, or a percentage of 60 FPS; see below), `version` | `GameSubscriber.cpp` |
| CPU core | `cpu.stepping`, `cpu.resume`, `cpu.status` (reports `ticks` plus `us`, emulated microseconds, and `clockHz` - use `us` to line up with wall-clock timings, since games change the clock frequency and the ticks-per-second ratio isn't fixed), `cpu.getAllRegs`, `cpu.getReg`, `cpu.setReg`, `cpu.evaluate` | `CPUCoreSubscriber.cpp` |
| Stepping | `cpu.stepInto`, `cpu.stepOver`, `cpu.stepOut`, `cpu.runUntil`, `cpu.runUntilTime` (run until a point in emulated time - `us` absolute or `relativeUs` from now - and break there; this is how to get a scripted repro reproducibly "N seconds into the game" instead of polling `cpu.status` in a loop), `cpu.nextHLE` | `SteppingSubscriber.cpp` |
| Breakpoints | `cpu.breakpoint.add/update/remove/list`, `memory.breakpoint.add/update/remove/list`, `cpu.regBreakpoint.add/update/remove/list` (break when a register is written to, by any instruction anywhere - currently GPRs only; interpreter-only, no effect under a JIT backend) | `BreakpointSubscriber.cpp` |