mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
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:
+12
-3
@@ -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
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user