Debugger: Add game.state.rewind and game.state.verify

Same as game.state.load/save, and the wait response now carries the
metadata string (for a rewind, how long ago the snapshot was taken).

Also fixes Broadcast() erasing a waiting entry while holding its mutex,
which could free the mutex while locked (the debug CRT aborts on it).

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 17:29:01 +02:00
1 parent e3916c0acc
commit 79c2b9b0fd
2 files changed
+64 -19

No files matched your search

+63 -18
View File
@@ -57,10 +57,18 @@ struct SaveStateResult {
bool done = false;
SaveState::Status status = SaveState::Status::FAILURE;
std::string message;
std::string metadata;
};
enum class StateOp {
LOAD,
SAVE,
REWIND,
VERIFY,
};
struct WebSocketGameState : public DebuggerSubscriber {
void StateLoadSave(DebuggerRequest &req, bool save);
void StateOperation(DebuggerRequest &req, StateOp op);
void Broadcast(net::WebSocketServer *ws) override;
private:
@@ -74,23 +82,32 @@ private:
} // namespace
// Load a savestate file (game.state.load), or save one (game.state.save)
// Load a savestate file (game.state.load), save one (game.state.save), rewind to the newest
// rewind snapshot (game.state.rewind), or check that saving and reading back the current state
// agree (game.state.verify)
//
// 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.
//
// Rewind snapshots are only taken with the RewindSnapshotInterval setting on. Each rewind uses up
// the snapshot it restores, so repeating it goes further back.
//
// Parameters:
// - path: string, path to the .ppst file.
// - path: string, path to the .ppst file (only for load and save).
// - 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";
// mode refuses it, or there's nothing to rewind to. With wait, it's sent once done instead, with
// message and metadata strings (for a rewind, how long ago the snapshot was taken), or an error
// if the operation failed.
void WebSocketGameState::StateOperation(DebuggerRequest &req, StateOp op) {
static const char *const events[] = { "game.state.load", "game.state.save", "game.state.rewind", "game.state.verify" };
const char *event = events[(int)op];
std::string path;
bool wait = false;
if (!req.ParamString("path", &path) || !req.ParamBool("wait", &wait, DebuggerParamType::OPTIONAL))
if ((op == StateOp::LOAD || op == StateOp::SAVE) && !req.ParamString("path", &path))
return;
if (!req.ParamBool("wait", &wait, DebuggerParamType::OPTIONAL))
return;
// The queued operation holds the only other reference, so Broadcast() can also tell when it's
@@ -98,7 +115,7 @@ void WebSocketGameState::StateLoadSave(DebuggerRequest &req, bool save) {
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) {
auto callback = [event, result](SaveState::Status status, std::string_view message, std::string_view metadata) {
if (status == SaveState::Status::FAILURE) {
ERROR_LOG(Log::System, "%s: %.*s", event, STR_VIEW(message));
}
@@ -106,22 +123,43 @@ void WebSocketGameState::StateLoadSave(DebuggerRequest &req, bool save) {
std::lock_guard<std::mutex> guard(result->lock);
result->status = status;
result->message = message;
result->metadata = metadata;
result->done = true;
}
};
bool running = false;
bool canRewind = true;
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;
}
switch (op) {
case StateOp::LOAD:
queued = SaveState::Load(Path(path), -1, callback);
break;
case StateOp::SAVE:
queued = SaveState::Save(Path(path), -1, callback);
break;
case StateOp::REWIND:
canRewind = SaveState::CanRewind();
if (canRewind) {
queued = SaveState::Rewind(callback);
}
break;
case StateOp::VERIFY:
queued = SaveState::Verify(callback);
break;
}
});
if (!running)
return req.Fail("Game not running");
if (!canRewind)
return req.Fail("No rewind snapshots");
if (!queued) {
return req.Fail(save ? "Could not queue savestate save" : "Could not queue savestate load");
return req.Fail(std::string("Could not queue ") + event);
}
if (!wait) {
@@ -136,17 +174,22 @@ 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) {
// Our own reference, since erasing the entry below must not free the mutex we're holding.
std::shared_ptr<SaveStateResult> result = it->result;
std::lock_guard<std::mutex> guard(result->lock);
if (!result->done && !dropped) {
++it;
continue;
}
bool failed = !it->result->done || it->result->status == SaveState::Status::FAILURE;
bool failed = !result->done || 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");
j.writeString("message", result->done ? result->message : "Savestate operation was dropped");
if (result->done) {
j.writeString("metadata", result->metadata);
}
if (failed) {
j.writeInt("level", (int)LogLevel::LERROR);
}
@@ -173,8 +216,10 @@ DebuggerSubscriber *WebSocketGameInit(DebuggerEventHandlerMap &map) {
auto p = new WebSocketGameState();
map["game.reset"] = &WebSocketGameReset;
map["game.status"] = &WebSocketGameStatus;
map["game.state.load"] = [p](DebuggerRequest &req) { p->StateLoadSave(req, false); };
map["game.state.save"] = [p](DebuggerRequest &req) { p->StateLoadSave(req, true); };
map["game.state.load"] = [p](DebuggerRequest &req) { p->StateOperation(req, StateOp::LOAD); };
map["game.state.save"] = [p](DebuggerRequest &req) { p->StateOperation(req, StateOp::SAVE); };
map["game.state.rewind"] = [p](DebuggerRequest &req) { p->StateOperation(req, StateOp::REWIND); };
map["game.state.verify"] = [p](DebuggerRequest &req) { p->StateOperation(req, StateOp::VERIFY); };
map["game.speed.get"] = &WebSocketGameSpeedGet;
map["game.speed.set"] = &WebSocketGameSpeedSet;
map["version"] = &WebSocketVersion;