Mods: named debug commands (psx_mod_register_command, TCP mod_cmd) (#711)

This commit is contained in:
yacineolivier-maker
2026-10-21 23:45:08 +02:00
committed by GitHub
parent da0c98b936
commit 030a50415e
6 changed files with 142 additions and 2 deletions
+22 -2
View File
@@ -493,9 +493,9 @@ between Play and TCP availability.
## Complete command index (generated)
**376 commands registered** — 363 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**383 commands registered** — 370 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
84 of 376 have prose above; **292 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
85 of 383 have prose above; **298 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -574,6 +574,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `dirty_ram_unsupported` | ✓ | | |
| `disasm` | ✓ | | |
| `disc_select` | ✓ | | |
| `disc_tray_log` | ✓ | | |
| `disp_ring` | ✓ | | |
| `dispatch_check` | ✓ | | |
| `dispatch_stats` | ✓ | | |
@@ -626,6 +627,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `frame_perf` | ✓ | | |
| `frame_range` | ✓ | ✓ | ✓ |
| `frame_timeseries` | ✓ | ✓ | ✓ |
| `frame_wall_ring` | ✓ | | |
| `freeze_check` | ✓ | | |
| `game_options` | ✓ | | |
| `gcp_ring` | ✓ | | ✓ |
@@ -657,12 +659,14 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `hd_textures` | ✓ | | ✓ |
| `history` | ✓ | ✓ | ✓ |
| `hle_dump` | ✓ | | ✓ |
| `host_button` | ✓ | | |
| `host_key` | ✓ | | |
| `host_launch_timings` | ✓ | | ✓ |
| `host_mouse` | ✓ | | |
| `host_osd_shot` | ✓ | | |
| `host_pause` | ✓ | | |
| `host_profile` | ✓ | | |
| `host_span_ring` | ✓ | | |
| `idle_skip` | ✓ | | |
| `imask_trace` | ✓ | | |
| `input_route_append` | ✓ | | ✓ |
@@ -691,6 +695,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `mmio_clear` | ✓ | | ✓ |
| `mmio_dump` | ✓ | | ✓ |
| `mmx6_freshfix` | ✓ | | |
| `mod_cmd` | ✓ | | ✓ |
| `mod_counters` | ✓ | | |
| `nclip_stats` | ✓ | | |
| `netplay_status` | ✓ | | |
@@ -772,6 +777,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `rtrace_reset` | | ✓ | |
| `rtrace_stats` | ✓ | ✓ | |
| `run_to_frame` | ✓ | | ✓ |
| `runtime_menu` | ✓ | | |
| `s3_smear_watch` | ✓ | | ✓ |
| `savestate` | ✓ | | |
| `savestate_status` | ✓ | | ✓ |
@@ -781,6 +787,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `screenshot_hires` | ✓ | | ✓ |
| `screenshot_wide_hires` | ✓ | | ✓ |
| `segment_misses` | ✓ | | |
| `session_restart` | ✓ | | |
| `set_input` | ✓ | ✓ | ✓ |
| `set_snapshot` | ✓ | ✓ | ✓ |
| `sio_arm_audit` | ✓ | | |
@@ -882,3 +889,16 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
`wtrace_dump pc_lo=<hex> pc_hi=<hex>` filters the recorded producer PC, masked to the physical address (KSEG0/KSEG1/KUSEG alias, like `addr_lo`/`addr_hi`), with inclusive lower and exclusive upper bounds, before limiting results. Omitted bounds match every entry; DMA entries use their recorded initiator, and a DMA whose initiator is unknown (0) never matches an explicit PC bound. Address/frame/order filters still apply.
`disasm addr=<hex> count=<1..256>` inspects aligned guest instructions; default count is 16. Resolved targets use the guest PC. Unknown words are shown verbatim.
## Trusted mod tuning commands
| Command | Parameters | Behavior |
|---|---|---|
| `mod_cmd` | `name`, optional `args` | Calls a statically linked trusted debug callback and returns its integer `result` and escaped text `reply`. |
The process owns up to 16 names, each 1-63 bytes. Registering the same name
replaces its callback. Registration is independent of package activation;
this is a debug-control registry, not a way to activate a disabled mod.
Callbacks run on the debug-server thread and may only touch state guarded
against its other owners. Guest/shared-state changes must be requested through
the plugin's existing emulation callback and obey its session/netplay policy.
+1
View File
@@ -1423,6 +1423,7 @@ if(BUILD_TESTING)
endif()
endif()
add_test(NAME mod_runtime_test COMMAND mod_runtime_test)
add_test(NAME mod_commands_test COMMAND mod_runtime_test --commands-only)
add_test(NAME mod_netplay_missing_plugin_test COMMAND mod_runtime_test --missing-profile)
add_test(NAME mod_netplay_missing_renderer_test COMMAND mod_runtime_test --missing-renderer)
add_test(NAME mod_netplay_profile_options_test COMMAND mod_runtime_test --profile-options)
+14
View File
@@ -102,6 +102,20 @@ extern uint32_t g_psx_mod_function_entry_hooks;
* kept, further names are counted in an overflow bucket. Emulation-thread only.
* TCP: {"cmd":"mod_counters"} lists every counter with its last frame. */
void psx_mod_counter_add(const char* name, uint32_t delta);
/* Named debug commands for trusted plugins (test benches, tuning): `fn` gets the
* argument string and writes a short text reply (truncated to `cap`); it runs on
* the debug-server thread, so it must only touch state it guards itself. Up to 16
* names (1..63 bytes, copied by the registry); callbacks have process lifetime.
* Re-registering a name replaces its callback, allowing restart registration.
* This debug-control registry is independent of package activation. It must
* not directly mutate guest/shared simulation state from the server thread:
* signal a guarded control request for the plugin's existing emulation hook.
* Callbacks must not throw. TCP: {"cmd":"mod_cmd","name":"<name>","args":"..."}. */
typedef int (*PSXModCommandFn)(const char* args, char* reply, int cap);
int psx_mod_register_command(const char* name, PSXModCommandFn fn);
/* Runs a registered command; -1 for unknown name/invalid output, otherwise the
* callback result. Valid replies are always terminated within cap bytes. */
int psx_mod_run_command(const char* name, const char* args, char* reply, int cap);
/* Presentation-only filtering: 0 nearest, 1 bilinear, 2 stable minification.
* Mode 2 uses a bounded palette-aware footprint for proven 3D polygons on
* OpenGL; untracked UI stays nearest. Other backends use bilinear. A session
+21
View File
@@ -8543,6 +8543,26 @@ static void handle_host_launch_timings(int id, const char *json)
free(events);
}
/* mod_cmd: {"cmd":"mod_cmd","name":"...","args":"..."} runs a plugin's named
* debug command (psx_mod_register_command) and returns its text reply. */
static void handle_mod_cmd(int id, const char *json)
{
extern int psx_mod_run_command(const char *name, const char *args, char *reply, int cap);
char name[64], args[1024], reply[1024], esc[2048];
if (!json_get_str(json, "name", name, sizeof name)) { send_err(id, "name required"); return; }
if (!json_get_str(json, "args", args, sizeof args)) args[0] = '\0';
const int r = psx_mod_run_command(name, args, reply, (int)sizeof reply);
if (r == -1) { send_err(id, "unknown mod command"); return; }
size_t e = 0;
for (const char *p = reply; *p && e + 3 < sizeof esc; p++) {
if (*p == '"' || *p == '\\') esc[e++] = '\\';
if ((unsigned char)*p >= 0x20) esc[e++] = *p;
else if (*p == '\n') { esc[e++] = '\\'; esc[e++] = 'n'; }
}
esc[e] = '\0';
send_fmt("{\"id\":%d,\"ok\":true,\"result\":%d,\"reply\":\"%s\"}\n", id, r, esc);
}
static void handle_mod_counters(int id, const char *json)
{
extern int psx_mod_counters_snapshot(const char **names, uint64_t *counts,
@@ -16168,6 +16188,7 @@ static const CmdEntry s_commands[] = {
{ "ws_hud_mode", handle_ws_hud_mode },
{ "ws_tag_stats", handle_ws_tag_stats },
{ "mod_counters", handle_mod_counters },
{ "mod_cmd", handle_mod_cmd },
{ "host_launch_timings", handle_host_launch_timings },
{ "resident_status", handle_resident_status },
{ "netplay_status", handle_netplay_status },
+35
View File
@@ -45,6 +45,7 @@ extern "C" const char* host_launch_timing_stage_name(uint32_t stage) {
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <optional>
#include <fstream>
#include <iomanip>
@@ -3311,6 +3312,40 @@ size_t g_mod_counter_count = 0;
uint64_t g_mod_counter_overflow = 0;
} // namespace
namespace {
struct ModCommand { char name[64]; PSXModCommandFn fn; };
std::mutex g_mod_commands_lock;
ModCommand g_mod_commands[16];
int g_mod_command_count = 0;
} // namespace
extern "C" int psx_mod_register_command(const char* name, PSXModCommandFn fn) {
if (!name || !name[0] || std::strlen(name) >= sizeof(g_mod_commands[0].name) || !fn) return 0;
std::lock_guard<std::mutex> lock(g_mod_commands_lock);
for (int i = 0; i < g_mod_command_count; ++i)
if (std::strcmp(g_mod_commands[i].name, name) == 0) { g_mod_commands[i].fn = fn; return 1; }
if (g_mod_command_count >= 16) return 0;
auto& entry = g_mod_commands[g_mod_command_count++];
std::memcpy(entry.name, name, std::strlen(name) + 1);
entry.fn = fn;
return 1;
}
extern "C" int psx_mod_run_command(const char* name, const char* args, char* reply, int cap) {
if (!reply || cap <= 0) return -1;
PSXModCommandFn fn = nullptr;
{
std::lock_guard<std::mutex> lock(g_mod_commands_lock);
for (int i = 0; i < g_mod_command_count; ++i)
if (name && std::strcmp(g_mod_commands[i].name, name) == 0) fn = g_mod_commands[i].fn;
}
if (cap > 0) reply[0] = '\0';
if (!fn) return -1;
const int result = fn(args ? args : "", reply, cap);
reply[cap - 1] = '\0';
return result;
}
extern "C" void psx_mod_counter_add(const char* name, uint32_t delta) {
if (!name || !name[0]) return;
for (size_t i = 0; i < g_mod_counter_count; i++) {
+49
View File
@@ -17,6 +17,7 @@
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <stdexcept>
#include <filesystem>
#include <fstream>
@@ -438,7 +439,55 @@ static uint64_t startup_counter(const char* requested) {
return 0;
}
static int test_command_calls;
static int command_fill_reply(const char* args, char* reply, int cap) {
++test_command_calls;
std::memset(reply, 'x', (size_t)cap); // deliberately fills the entire bound
return std::strcmp(args, "test") == 0 ? 17 : 11;
}
static int command_replacement(const char*, char* reply, int) { reply[0] = 0; return 99; }
static void test_commands(void) {
char name[] = "qa.owned";
check(psx_mod_register_command(name, command_fill_reply) == 1, "register owned command name");
std::memset(name, '?', sizeof(name)-1);
struct { char reply[8]; char guard; } buffer = {{0}, 'g'};
check(psx_mod_run_command("qa.owned", "test", buffer.reply, sizeof(buffer.reply)) == 17,
"command owns name and forwards args/result");
check(buffer.reply[7] == 0 && std::strlen(buffer.reply) == 7 && buffer.guard == 'g',
"full-capacity reply is bounded and terminated");
check(psx_mod_run_command("qa.owned", nullptr, buffer.reply, 1) == 11 && buffer.reply[0] == 0,
"one-byte reply and null args are bounded");
const int before = test_command_calls;
check(psx_mod_run_command("qa.owned", "", nullptr, 8) == -1 &&
psx_mod_run_command("qa.owned", "", buffer.reply, 0) == -1 &&
psx_mod_run_command("missing", "", buffer.reply, 8) == -1 &&
test_command_calls == before, "invalid or unknown command does not invoke callback");
check(!psx_mod_register_command(nullptr, command_fill_reply) &&
!psx_mod_register_command("", command_fill_reply) &&
!psx_mod_register_command("missing.callback", nullptr) &&
!psx_mod_register_command(std::string(64, 'a').c_str(), command_fill_reply),
"invalid and unreachable names are refused");
check(psx_mod_register_command("qa.owned", command_replacement) &&
psx_mod_run_command("qa.owned", "", buffer.reply, 8) == 99,
"duplicate registration explicitly replaces callback");
const auto maximum = std::string(63, 'b');
check(psx_mod_register_command(maximum.c_str(), command_replacement) == 1,
"63-byte command name is reachable");
for (int i = 0; i < 14; ++i)
check(psx_mod_register_command(("qa.extra." + std::to_string(i)).c_str(), command_replacement) == 1,
"temporary names fill bounded registry");
check(!psx_mod_register_command("qa.overflow", command_replacement) &&
psx_mod_run_command("qa.extra.13", "", buffer.reply, 8) == 99 &&
psx_mod_run_command("qa.owned", "", buffer.reply, 8) == 99,
"overflow preserves existing owned registrations");
}
int main(int argc, char** argv) {
test_commands();
if (argc > 1 && std::string(argv[1]) == "--commands-only") {
std::cout << "mod command tests: " << failures << " failures\n";
return failures ? 1 : 0;
}
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-cache-test";
const auto isolated_cache = root / "test-cache";