diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 0321f0ed64..338d19a828 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -1007,6 +1007,11 @@ struct Pcsx2Config /// (i.e. renderer change, swap chain mode change, etc.) bool RestartOptionsAreEqual(const GSOptions& right) const; + /// Whether changing this INI key forces a GS device teardown, i.e. whether it is + /// one of the fields RestartOptionsAreEqual compares. Lets a caller mutating a + /// setting by name report the cost without diffing two whole configs. + static bool IsRestartOption(const char* ini_key); + /// Returns false if any options need to be applied to the MTGS. bool OptionsAreEqual(const GSOptions& right) const; diff --git a/pcsx2/PINE.cpp b/pcsx2/PINE.cpp index dd4059a4a3..852337ea21 100644 --- a/pcsx2/PINE.cpp +++ b/pcsx2/PINE.cpp @@ -5,11 +5,16 @@ #include "Common.h" #include "Host.h" #include "Elfheader.h" +#include "GS.h" +#include "GS/GSPerfMon.h" +#include "MTGS.h" +#include "PerformanceMetrics.h" #include "SaveState.h" #include "PINE.h" #include "VMManager.h" #include "vtlb.h" #include "common/Error.h" +#include "common/SettingsInterface.h" #include "common/Threading.h" #include @@ -159,6 +164,14 @@ namespace PINEServer MsgUUID = 0xD, /**< Returns the game UUID. */ MsgGameVersion = 0xE, /**< Returns the game verion. */ MsgStatus = 0xF, /**< Returns the emulator status. */ + + // ARMSX2-local extensions. Upstream PINE stops at 0xF; these are private to + // this fork, so a generic PINE client will simply never send them. + MsgGetStats = 0x10, /**< Returns host-side performance statistics as JSON. */ + 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. */ + MsgUnimplemented = 0xFF /**< Unimplemented IPC message. */ }; @@ -258,6 +271,85 @@ namespace PINEServer return !((command_len + command_size) > buf_size || (reply_len + reply_size) >= MAX_IPC_RETURN_SIZE); } + + /** + * Reads a length-prefixed ([u32 len][len bytes], no NUL) string argument, advancing + * buf_cnt past it. Returns false if the declared length runs off the end of the + * request, in which case the caller must fail the command. + */ + static bool ReadLengthPrefixedString(std::span buf, u32& buf_cnt, u32 buf_size, std::string* out) + { + if ((buf_cnt + 4) > buf_size) + return false; + + const u32 len = FromSpan(buf, buf_cnt); + buf_cnt += 4; + + // Bound the allocation by what the request could actually contain. + if (len > buf_size || (buf_cnt + len) > buf_size) + return false; + + out->assign(reinterpret_cast(&buf[buf_cnt]), len); + buf_cnt += len; + return true; + } + + /** + * Builds the host-side statistics document. Called on the PINE thread. + * + * PerformanceMetrics and g_perfmon are plain scalar reads -- racy against the GS + * thread but benign, since every field is an independently-meaningful number and a + * torn sample only costs one stale stat. GSgetStats/GSgetMemoryStats are NOT safe + * here: they dereference g_texture_cache and g_gs_device, which are GS-thread + * owned, so the texture-cache memory figures are gathered via RunOnGSThread. + */ + static std::string BuildStatsJson() + { + SmallString gs_memory; + if (MTGS::IsOpen()) + { + MTGS::RunOnGSThread([&gs_memory]() { GSgetMemoryStats(gs_memory); }); + MTGS::WaitGS(false); + } + + const auto counter = [](GSPerfMon::counter_t c) { return g_perfmon.Get(c); }; + + return fmt::format( + "{{" + "\"fps\":{:.3f},\"internal_fps\":{:.3f},\"speed\":{:.3f}," + "\"frame_ms_avg\":{:.3f},\"frame_ms_min\":{:.3f},\"frame_ms_max\":{:.3f}," + "\"cpu_thread_pct\":{:.3f},\"cpu_thread_ms\":{:.3f}," + "\"gs_thread_pct\":{:.3f},\"gs_thread_ms\":{:.3f}," + "\"vu_thread_pct\":{:.3f},\"vu_thread_ms\":{:.3f}," + "\"gpu_pct\":{:.3f},\"gpu_ms_avg\":{:.3f},\"gpu_ms_last\":{:.3f}," + "\"gpu_vs_invocations\":{:.0f},\"gpu_ps_invocations\":{:.0f}," + "\"prims\":{:.1f},\"draws\":{:.1f},\"draw_calls\":{:.1f}," + "\"render_passes\":{:.1f},\"barriers\":{:.1f},\"readbacks\":{:.1f}," + "\"texture_copies\":{:.1f},\"texture_uploads\":{:.1f}," + "\"draw_calls_rov\":{:.1f},\"barriers_rov\":{:.1f},\"texture_copies_rov\":{:.1f}," + "\"tc_source_hit\":{:.1f},\"tc_source_miss\":{:.1f}," + "\"tc_target_hit\":{:.1f},\"tc_target_miss\":{:.1f}," + "\"hash_cache_hit\":{:.1f},\"hash_cache_miss\":{:.1f}," + "\"gs_memory\":\"{}\",\"frame_number\":{}" + "}}", + PerformanceMetrics::GetFPS(), PerformanceMetrics::GetInternalFPS(), PerformanceMetrics::GetSpeed(), + PerformanceMetrics::GetAverageFrameTime(), PerformanceMetrics::GetMinimumFrameTime(), + PerformanceMetrics::GetMaximumFrameTime(), + PerformanceMetrics::GetCPUThreadUsage(), PerformanceMetrics::GetCPUThreadAverageTime(), + PerformanceMetrics::GetGSThreadUsage(), PerformanceMetrics::GetGSThreadAverageTime(), + PerformanceMetrics::GetVUThreadUsage(), PerformanceMetrics::GetVUThreadAverageTime(), + PerformanceMetrics::GetGPUUsage(), PerformanceMetrics::GetGPUAverageTime(), + PerformanceMetrics::GetLastGPUTime(), + PerformanceMetrics::GetGPUAverageVSInvocations(), PerformanceMetrics::GetGPUAveragePSInvocations(), + counter(GSPerfMon::Prim), counter(GSPerfMon::Draw), counter(GSPerfMon::DrawCalls), + counter(GSPerfMon::RenderPasses), counter(GSPerfMon::Barriers), counter(GSPerfMon::Readbacks), + counter(GSPerfMon::TextureCopies), counter(GSPerfMon::TextureUploads), + counter(GSPerfMon::DrawCallsROV), counter(GSPerfMon::BarriersROV), counter(GSPerfMon::TextureCopiesROV), + counter(GSPerfMon::TCSourceHit), counter(GSPerfMon::TCSourceMiss), + counter(GSPerfMon::TCTargetHit), counter(GSPerfMon::TCTargetMiss), + counter(GSPerfMon::HashCacheHit), counter(GSPerfMon::HashCacheMiss), + gs_memory.view(), PerformanceMetrics::GetFrameNumber()); + } } // namespace PINEServer bool PINEServer::Initialize(int slot) @@ -746,6 +838,79 @@ PINEServer::IPCBuffer PINEServer::ParseCommand(std::span buf, std::vectorGetStringValue(section.c_str(), key.c_str(), ""); + } + + const u32 size = value.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], value.c_str(), size); + ret_cnt += size; + break; + } + case MsgSetSetting: + { + std::string section, key, value; + if (!ReadLengthPrefixedString(buf, buf_cnt, buf_size, §ion) || + !ReadLengthPrefixedString(buf, buf_cnt, buf_size, &key) || + !ReadLengthPrefixedString(buf, buf_cnt, buf_size, &value)) [[unlikely]] + goto error; + + // Whether this change tears down the GS device, so the caller knows + // whether it just paid for a reopen. Everything outside this set is + // applied in place by GSUpdateConfig. + const bool restart_required = Pcsx2Config::GSOptions::IsRestartOption(key.c_str()); + + // Write the persisted key rather than poking EmuConfig directly: a direct + // poke is silently reverted by the next ApplySettings, which re-derives + // EmuConfig from the INI layer stack. + Host::SetBaseStringSettingValue(section.c_str(), key.c_str(), value.c_str()); + Host::CommitBaseSettingChanges(); + Host::RunOnCPUThread([]() { VMManager::ApplySettings(); }); + + const std::string reply = fmt::format("{{\"restart_required\":{}}}", restart_required ? "true" : "false"); + 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; + } + case MsgFrameAdvance: + { + if (!VMManager::HasValidVM()) + goto error; + if (!SafetyChecks(buf_cnt, 0, ret_cnt, 0, buf_size)) [[unlikely]] + goto error; + Host::RunOnCPUThread([]() { VMManager::FrameAdvance(1); }); + break; + } default: { error: diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index 7dd5ae9407..7a5e4c88ce 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -929,6 +929,37 @@ bool Pcsx2Config::GSOptions::operator!=(const GSOptions& right) const return !operator==(right); } +bool Pcsx2Config::GSOptions::IsRestartOption(const char* ini_key) +{ + // INI key names for the fields compared in RestartOptionsAreEqual below; keep the + // two in sync. Names match the field names except BackThreadMode, which is stored + // as "GSBackThreadMode". + static constexpr const char* keys[] = { + "Renderer", + "Adapter", + "UseDebugDevice", + "UseBlitSwapChain", + "DisableShaderCache", + "DisableFramebufferFetch", + "DisablePS2DepthQuantization", + "DisableVertexShaderExpand", + "EnableAdrenoFramebufferFetch", + "ForceMaliFramebufferFetch", + "OverrideTextureBarriers", + "DepthFeedbackMode", + "GSBackThreadMode", + "HWAA1", + "ExclusiveFullscreenControl", + }; + + for (const char* key : keys) + { + if (StringUtil::Strcasecmp(key, ini_key) == 0) + return true; + } + return false; +} + bool Pcsx2Config::GSOptions::RestartOptionsAreEqual(const GSOptions& right) const { return OpEqu(Renderer) && diff --git a/tools/gsctl.py b/tools/gsctl.py new file mode 100644 index 0000000000..0e61f3072b --- /dev/null +++ b/tools/gsctl.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 ARMSX2 Contributors +# SPDX-License-Identifier: GPL-3.0+ +"""Query and control a running ARMSX2 instance over its PINE socket. + +Built for GS performance work: read the statistics that normally only appear on +the OSD, and toggle settings without restarting the emulator. Most GS settings +apply in place, so a whole settings sweep can run against one booted instance +sitting on a savestate. + +Requires `EmuCore/EnablePINE = true` in the INI (or the Big Picture UI toggle). + +Examples: + gsctl.py stats + gsctl.py stats --watch 1.0 + gsctl.py get EmuCore/GS accurate_blending_unit + gsctl.py set EmuCore/GS accurate_blending_unit 3 + gsctl.py loadstate 2 + gsctl.py frameadvance + +Pure stdlib; no build step. Output is JSON on stdout so it composes with jq. +""" + +import argparse +import json +import os +import socket +import struct +import sys +import time + +DEFAULT_SLOT = 28011 + +# Opcodes. 0x00-0x0F are upstream PINE; 0x10+ are ARMSX2-local extensions. +MSG_SAVE_STATE = 0x09 +MSG_LOAD_STATE = 0x0A +MSG_TITLE = 0x0B +MSG_STATUS = 0x0F +MSG_GET_STATS = 0x10 +MSG_GET_SETTING = 0x11 +MSG_SET_SETTING = 0x12 +MSG_FRAME_ADVANCE = 0x13 + +IPC_OK = 0 +STATUS_NAMES = {0: "running", 1: "paused", 2: "shutdown"} + + +class PineError(Exception): + pass + + +def socket_path(slot): + """Mirrors PINEServer::Initialize. Note the emulator name is still 'pcsx2'.""" + if sys.platform == "darwin": + base = os.environ.get("TMPDIR", "/tmp") + else: + base = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + name = "pcsx2.sock" if slot == DEFAULT_SLOT else "pcsx2.sock.%d" % slot + return os.path.join(base, name) + + +def lp_string(s): + """Length-prefixed string argument: [u32 len][bytes], no NUL.""" + raw = s.encode("utf-8") + return struct.pack("/, got '%s'" % arg) + section, _, key = arg.rpartition("/") + return section, key + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--slot", type=int, default=DEFAULT_SLOT, help="PINE slot (default %d)" % DEFAULT_SLOT) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("stats", help="dump performance/GS statistics as JSON") + p.add_argument("--watch", type=float, metavar="SECONDS", + help="poll forever at this interval, one JSON object per line") + + sub.add_parser("status", help="running / paused / shutdown") + sub.add_parser("title", help="current game title") + sub.add_parser("frameadvance", help="advance a paused VM by one frame") + + p = sub.add_parser("get", help="read a setting") + p.add_argument("section") + p.add_argument("key", nargs="?") + + p = sub.add_parser("set", help="write a setting and apply it") + p.add_argument("section") + p.add_argument("key") + p.add_argument("value", nargs="?") + + p = sub.add_parser("loadstate", help="load a savestate slot") + p.add_argument("slot", type=int) + + p = sub.add_parser("savestate", help="save to a savestate slot") + p.add_argument("slot", type=int) + + args = ap.parse_args() + + try: + with Pine(args.slot) as pine: + if args.cmd == "stats": + if args.watch: + while True: + print(json.dumps(pine.stats()), flush=True) + time.sleep(args.watch) + else: + print(json.dumps(pine.stats(), indent=2)) + elif args.cmd == "status": + print(pine.status()) + elif args.cmd == "title": + print(pine.title()) + elif args.cmd == "frameadvance": + pine.frame_advance() + elif args.cmd == "get": + # Accept both 'get EmuCore/GS Key' and 'get EmuCore/GS/Key'. + section, key = (args.section, args.key) if args.key else split_section_key(args.section) + print(pine.get_setting(section, key)) + elif args.cmd == "set": + if args.value is None: + section, key = split_section_key(args.section) + value = args.key + else: + section, key, value = args.section, args.key, args.value + result = pine.set_setting(section, key, value) + print(json.dumps(result)) + if result.get("restart_required"): + print("note: this key forces a GS device reopen", file=sys.stderr) + elif args.cmd == "loadstate": + pine.load_state(args.slot) + elif args.cmd == "savestate": + pine.save_state(args.slot) + except KeyboardInterrupt: + pass + except (PineError, OSError) as e: + print("gsctl: %s" % e, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())