PINE: add a GS-dump opcode so a script can capture without a hotkey

MsgGSDump (0x14, ARMSX2-local) queues a GS dump of the next N frames:
[u32 frames][u32 path_len][path bytes], where frames == 0 stops a recording
dump and UINT32_MAX records until stopped -- the same press/release pair the
GSDumpMultiFrame hotkey binds. The reply is JSON carrying the resolved dump
path, so a client knows the file to wait for instead of guessing at the
snapshots folder's auto-naming.

Three things the naive version of this gets wrong, all found by testing it
against a live Dragon Quest VIII:

QueueSnapshot honours a caller-supplied path only when it ends in .png, and
silently substitutes an auto-named file otherwise -- a scripted client would
write somewhere it never looks. Normalise the path up front instead, dropping
a .gs/.gs.xz/.gs.zst/.png suffix if the caller spelled one out so that naming
the file you want does not earn a doubled extension.

A request that arrives while a dump is already recording creates no second
dump: the VSync handler only opens one when none exists. It writes a stray
screenshot, and worse, overwrites the running dump's remaining frame count and
cuts it short. The first version of this replied with a path for a file that
was never created and truncated the recording that was. Refuse instead, with
reason "already recording"; the caller can stop the running dump first. The
same defect reachable via the Screenshot hotkey is left alone here -- it is a
renderer behaviour change and belongs in its own commit.

The PINE thread cannot push MTGS packets: the ring is single-producer and that
producer is the EE thread. Take the same two-hop route BuildStatsJson already
documents -- Host::RunOnCPUThread, then RunOnGSThread -- and read GSConfig's
compression method on the GS thread, since it decides the extension.

QueueSnapshot and GSQueueSnapshot now return whether they took the request;
existing callers ignore it. GSIsDumpRecording and GSHasFrontParser expose the
two pieces of GS-thread state the reply needs. pipelined_incomplete surfaces
the known GV7-2 gap rather than letting a script collect corrupt dumps.

Verified live: every promised path was written, refusals produced no files,
and all three dump shapes replay in gsrunner -- single-frame as 4 (2) frames,
a stopped multi-frame recording as 186 (91).
This commit is contained in:
Brian Degenhardt
2026-07-30 21:55:58 -07:00
parent bf65e8604b
commit 975e408ed5
5 changed files with 189 additions and 7 deletions
+12 -3
View File
@@ -653,7 +653,7 @@ int GSfreeze(FreezeAction mode, freezeData* data)
}
}
void GSQueueSnapshot(const std::string& path, u32 gsdump_frames)
bool GSQueueSnapshot(const std::string& path, u32 gsdump_frames)
{
// GV7-1d-ii known gap: the GSDump transfer hook sits on the parse path, so
// under the two-object split the front's transfers would be missing from
@@ -661,8 +661,7 @@ void GSQueueSnapshot(const std::string& path, u32 gsdump_frames)
if (g_gs_front)
Console.Warning("GS: dump recording under GSBackThreadMode=Pipelined is not yet supported; expect an incomplete dump.");
if (g_gs_renderer)
g_gs_renderer->QueueSnapshot(path, gsdump_frames);
return g_gs_renderer && g_gs_renderer->QueueSnapshot(path, gsdump_frames);
}
void GSStopGSDump()
@@ -671,6 +670,16 @@ void GSStopGSDump()
g_gs_renderer->StopGSDump();
}
bool GSIsDumpRecording()
{
return g_gs_renderer && g_gs_renderer->IsDumpRecording();
}
bool GSHasFrontParser()
{
return static_cast<bool>(g_gs_front);
}
bool GSBeginCapture(std::string filename)
{
if (g_gs_renderer)
+10 -1
View File
@@ -88,7 +88,16 @@ bool GSGetPresentCapSuspended();
int GSfreeze(FreezeAction mode, freezeData* data);
std::string GSGetBaseSnapshotFilename();
std::string GSGetBaseVideoFilename();
void GSQueueSnapshot(const std::string& path, u32 gsdump_frames = 0);
// False if there is no renderer, or a snapshot is already queued and this request was dropped.
bool GSQueueSnapshot(const std::string& path, u32 gsdump_frames = 0);
// True while a dump is open and taking frames. Queueing a snapshot over one of these does not
// start a second dump -- it overwrites the remaining frame count and cuts the recording short.
bool GSIsDumpRecording();
// True when the two-object split is live: a pipelined back thread with its own front parser.
// Not the same question as the BackThreadMode setting, which downgrades to lockstep when the
// split is unsupported. GS dumps recorded in this mode are incomplete -- the transfer hook
// sits on the parse path, so the front's transfers never reach the dump.
bool GSHasFrontParser();
void GSStopGSDump();
bool GSBeginCapture(std::string filename);
void GSEndCapture();
+3 -2
View File
@@ -1157,10 +1157,10 @@ void GSRenderer::VSync(u32 field, bool registers_written, bool idle_frame)
DumpTransferImages();
}
void GSRenderer::QueueSnapshot(const std::string& path, const u32 gsdump_frames)
bool GSRenderer::QueueSnapshot(const std::string& path, const u32 gsdump_frames)
{
if (!m_snapshot.empty())
return;
return false;
// Allows for providing a complete path
if (path.size() > 4 && StringUtil::EndsWithNoCase(path, ".png"))
@@ -1170,6 +1170,7 @@ void GSRenderer::QueueSnapshot(const std::string& path, const u32 gsdump_frames)
// this is really gross, but wx we get the snapshot request after shift...
m_dump_frames = gsdump_frames;
return true;
}
static std::string GSGetBaseFilename()
+5 -1
View File
@@ -67,7 +67,11 @@ public:
bool SaveSnapshotToMemory(u32 window_width, u32 window_height, bool apply_aspect, bool crop_borders,
u32* width, u32* height, std::vector<u32>* pixels);
void QueueSnapshot(const std::string& path, const u32 gsdump_frames);
// False if a snapshot is already queued and this request was dropped.
bool QueueSnapshot(const std::string& path, const u32 gsdump_frames);
// True while a dump is open and taking frames. A queued snapshot does not count: the
// dump is not created until the VSync that services it.
bool IsDumpRecording() const { return static_cast<bool>(m_dump); }
void StopGSDump();
void PresentCurrentFrame();
bool BeginCapture(std::string filename, const GSVector2i& size = GSVector2i(0, 0));
+159
View File
@@ -172,6 +172,7 @@ namespace PINEServer
MsgGetSetting = 0x11, /**< Reads a setting by section/key. */
MsgSetSetting = 0x12, /**< Writes a setting by section/key and applies it. */
MsgFrameAdvance = 0x13, /**< Advances a paused VM by one frame. */
MsgGSDump = 0x14, /**< Records a GS dump of the next N frames. */
MsgUnimplemented = 0xFF /**< Unimplemented IPC message. */
};
@@ -389,6 +390,135 @@ namespace PINEServer
gs_memory.view(), PerformanceMetrics::GetFrameNumber(),
Pcsx2Config::GSOptions::GetRendererName(EmuConfig.GS.Renderer), device_name, driver_info);
}
/**
* Escapes a string for embedding in a JSON string literal. Only paths go through this
* -- a Windows path is full of backslashes, which would otherwise leave the reply
* unparseable. BuildStatsJson's sanitizer is a different job: it flattens driver blurb
* that is allowed to lose fidelity, whereas a path the client is about to open is not.
*/
static std::string JsonEscape(const std::string_view str)
{
std::string out;
out.reserve(str.size());
for (const char c : str)
{
if (c == '"' || c == '\\')
out.push_back('\\');
out.push_back(c);
}
return out;
}
/**
* Queues a GS dump of the next `frames` frames, or stops a dump already recording when
* `frames` is zero -- the same pair of actions the GSDumpMultiFrame hotkey binds to press
* and release. `path` may be empty for the usual auto-named file under the snapshots
* folder. Returns false only if there is no GS to dump, which fails the command.
*
* Marshalled the same way as BuildStatsJson and for the same reason: the MTGS ring is
* single-producer and the PINE thread is not that producer, so pushing a packet from
* here races the EE thread's own writes and can deadlock the two of them. Hop to the CPU
* thread, push from there, and block until the GS thread has taken the request.
*
* The reply describes the request, not a finished file. The dump is opened on the next
* VSync and a multi-frame dump is closed some frames after that, so a client that waits
* for the file has to keep the VM running or step it with MsgFrameAdvance -- a paused VM
* never reaches the VSync that writes anything. A screenshot lands next to the dump too;
* that is inherent to the snapshot path, not something this command adds.
*
* `queued` false carries a `reason`, because both ways a request can be turned away are
* served commands that quietly did nothing rather than socket-level failures. The reasons
* are not symmetric: "snapshot pending" is a request that arrived between another one and
* the VSync servicing it, and retrying a frame later works. "already recording" is a
* refusal -- the snapshot path creates a dump only when none exists, so a second request
* would take a screenshot, write no dump, and overwrite the frame counter of the dump
* already running, cutting it short. Handing back a path for a file that will never appear
* is the worst of the available answers, so stop the running dump first if you mean to.
*/
static bool QueueGSDump(u32 frames, std::string path, std::string* reply)
{
if (!MTGS::IsOpen())
return false;
// QueueSnapshot honours a caller-supplied path only when it ends in .png, which it
// strips to get the base name shared by the screenshot and the dump; anything else is
// silently discarded in favour of an auto-named file in the snapshots folder. Silence
// is the wrong failure for a scripted client -- it writes somewhere the script never
// looks -- so meet that contract here instead. Drop a dump or image suffix if the
// caller spelled one out, so naming the file you want does not earn you a doubled
// extension, and let what is left be the base name.
if (!path.empty())
{
for (const std::string_view suffix : {".gs.zst", ".gs.xz", ".gs", ".png"})
{
if (StringUtil::EndsWithNoCase(path, suffix))
{
path.erase(path.size() - suffix.size());
break;
}
}
}
bool queued = false, stopped = false, incomplete = false;
std::string base;
const char* dump_ext = "";
const char* reason = "";
Host::RunOnCPUThread(
[&]() {
MTGS::RunOnGSThread([&]() {
if (frames == 0)
{
GSStopGSDump();
stopped = true;
return;
}
if (GSIsDumpRecording())
{
reason = "already recording";
return;
}
// Resolve the auto-name here rather than leaving it to QueueSnapshot, so the
// reply can name the exact file the client is about to wait for.
base = path.empty() ? GSGetBaseSnapshotFilename() : path;
queued = GSQueueSnapshot(base + ".png", frames);
if (!queued)
{
reason = "snapshot pending";
return;
}
// GSConfig is the GS thread's copy of the config, so read the compression
// method here -- it decides the extension the dump writer will append.
switch (GSConfig.GSDumpCompression)
{
case GSDumpCompressionMethod::Uncompressed:
dump_ext = ".gs";
break;
case GSDumpCompressionMethod::LZMA:
dump_ext = ".gs.xz";
break;
default:
dump_ext = ".gs.zst";
break;
}
incomplete = GSHasFrontParser();
});
MTGS::WaitGS(false);
},
true);
*reply = fmt::format(
"{{\"queued\":{},\"stopped\":{},\"frames\":{},\"path\":\"{}\","
"\"pipelined_incomplete\":{},\"reason\":\"{}\"}}",
queued ? "true" : "false", stopped ? "true" : "false", frames,
queued ? JsonEscape(base + dump_ext) : std::string(), incomplete ? "true" : "false", reason);
return true;
}
} // namespace PINEServer
bool PINEServer::Initialize(int slot)
@@ -950,6 +1080,35 @@ PINEServer::IPCBuffer PINEServer::ParseCommand(std::span<u8> buf, std::vector<u8
Host::RunOnCPUThread([]() { VMManager::FrameAdvance(1); });
break;
}
case MsgGSDump:
{
// [u32 frames][u32 path_len][path_len bytes]. frames == 0 stops a dump that is
// already recording; UINT32_MAX records until something stops it.
if (!VMManager::HasValidVM())
goto error;
if (!SafetyChecks(buf_cnt, 4, ret_cnt, 0, buf_size)) [[unlikely]]
goto error;
const u32 frames = FromSpan<u32>(buf, buf_cnt);
buf_cnt += 4;
std::string path;
if (!ReadLengthPrefixedString(buf, buf_cnt, buf_size, &path)) [[unlikely]]
goto error;
std::string reply;
if (!QueueGSDump(frames, std::move(path), &reply)) [[unlikely]]
goto error;
const u32 size = reply.size() + 1;
if (!SafetyChecks(buf_cnt, 0, ret_cnt, size + 4, buf_size)) [[unlikely]]
goto error;
ToResultVector(ret_buffer, size, ret_cnt);
ret_cnt += 4;
memcpy(&ret_buffer[ret_cnt], reply.c_str(), size);
ret_cnt += size;
break;
}
default:
{
error: