From 030a50415e97e60c77501abf7965fefa7e36ee5b Mon Sep 17 00:00:00 2001 From: yacineolivier-maker Date: Wed, 21 Oct 2026 23:45:08 +0200 Subject: [PATCH] Mods: named debug commands (psx_mod_register_command, TCP mod_cmd) (#711) --- docs/TCP_COMMANDS.md | 24 +++++++++++++-- runtime/CMakeLists.txt | 1 + runtime/include/mod_plugins.h | 14 +++++++++ runtime/src/debug_server.c | 21 +++++++++++++ runtime/src/mod_runtime.cpp | 35 +++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 49 ++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index d937707a..0d11ca8c 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -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":""}` 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":""}` 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= pc_hi=` 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= 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. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ea1d52bc..11e7afbc 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 8c6743a3..1038cd8e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -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":"","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 diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 31f1b0bd..af45cb55 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -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 }, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 5bfe6257..7fa5a8c2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -45,6 +45,7 @@ extern "C" const char* host_launch_timing_stage_name(uint32_t stage) { #include #include #include +#include #include #include #include @@ -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 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 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++) { diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 71680a21..c478d9ba 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -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";