diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 5fcf00a5..ced4665e 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -430,8 +430,6 @@ If an inspection need isn't covered by the existing commands, **do not fall back The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` is absolute: **no `fprintf(stderr, …)` in source code, ever, for any reason**. - - ## `host_launch_timings` — retained host startup and mod-commit costs `{"cmd":"host_launch_timings","count":256}` reads the newest completed host @@ -461,11 +459,13 @@ including Resident Loading when enabled. No work is moved off-thread and no verification or cache policy changes. These scopes do not cover every action between Play and TCP availability. + + ## Complete command index (generated) -**349 commands registered** — 336 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**352 commands registered** — 339 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -68 of 349 have prose above; **281 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. +69 of 352 have prose above; **283 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. @@ -615,6 +615,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `guest_tty_dump` | ✓ | | | | `history` | ✓ | ✓ | ✓ | | `hle_dump` | ✓ | | ✓ | +| `host_launch_timings` | ✓ | | ✓ | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | | `input_route_append` | ✓ | | | @@ -683,6 +684,8 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `pgxp` | ✓ | | ✓ | | `pgxp_miss_ring` | ✓ | | | | `pgxp_shadow` | ✓ | | | +| `pgxp_store_ring` | ✓ | | | +| `pgxp_tri_ring` | ✓ | | | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | ✓ | | `ping` | ✓ | ✓ | ✓ | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 830c43b3..b39dd726 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -135,6 +135,12 @@ if(BUILD_TESTING) set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture") find_package(Python3 QUIET COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + add_test(NAME host_launch_timings_protocol_test COMMAND ${CMAKE_COMMAND} -E env + "CC=${CMAKE_C_COMPILER}" "CXX=${CMAKE_CXX_COMPILER}" "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_host_launch_timings_protocol.py") + set_tests_properties(host_launch_timings_protocol_test PROPERTIES SKIP_RETURN_CODE 77) + add_test(NAME tcp_command_index_check COMMAND "${Python3_EXECUTABLE}" + "${PSXRECOMP_ROOT}/tools/gen_tcp_commands.py" --check) add_test(NAME wtrace_dump_test COMMAND ${CMAKE_COMMAND} -E env "CC=${CMAKE_C_COMPILER}" "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_wtrace_dump.py") diff --git a/runtime/tests/test_host_launch_timings_protocol.py b/runtime/tests/test_host_launch_timings_protocol.py new file mode 100644 index 00000000..96d9aafc --- /dev/null +++ b/runtime/tests/test_host_launch_timings_protocol.py @@ -0,0 +1,112 @@ +"""Execute the production TCP handler/helpers and real ring through the C ABI. + +No server, game, SDL, or persistent log. Missing compilers are an explicit skip. +""" +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_wtrace_dump import matching_brace + +ROOT = Path(__file__).resolve().parents[2] + + +def function(source, name): + match = re.search(r'^(?:static |extern "C" )[^;{}]+\b' + name + + r'\([^;{}]*\)\s*\{', source, re.M) + if not match: + raise AssertionError(f"production function missing: {name}") + return source[match.start():matching_brace(source, match.end() - 1) + 1] + + +class HostLaunchProtocol(unittest.TestCase): + @classmethod + def setUpClass(cls): + cc, cxx = os.environ.get("CC", "cc"), os.environ.get("CXX", "c++") + if not shutil.which(cc) or not shutil.which(cxx): + raise unittest.SkipTest("set CC and CXX to the native compilers") + cls.temp = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.temp.cleanup) + temp = Path(cls.temp.name) + debug = (ROOT / "runtime/src/debug_server.c").read_text(encoding="utf-8") + runtime = (ROOT / "runtime/src/mod_runtime.cpp").read_text(encoding="utf-8") + harness = '''#include +#include +#include +#include "host_launch_timing.h" +void seed_host_launch_timing_fixture(int count); +static void debug_server_send_line(const char *line) { puts(line); } +static void send_err(int id, const char *error) { (void)id; (void)error; abort(); } +''' + for name in ("json_get_int", "json_escape_string", "handle_host_launch_timings"): + harness += function(debug, name) + "\n" + harness += '''int main(int argc, char **argv) { + if (argc != 3) return 2; + seed_host_launch_timing_fixture(atoi(argv[2])); + handle_host_launch_timings(42, argv[1]); + return 0; +} +''' + bridge = '#include "host_launch_timing.h"\n' + for name in ("host_launch_timing_snapshot", "host_launch_timing_stage_name"): + bridge += function(runtime, name) + "\n" + bridge += '''extern "C" void seed_host_launch_timing_fixture(int count) { + char label[64]; + for (unsigned i = 0; i < 63; ++i) label[i] = i % 3 == 0 ? '"' : i % 3 == 1 ? '\\\\' : '\\1'; + label[63] = 0; + for (int i = 0; i < count; ++i) { + PSXRecompV4::HostLaunchTimingScope scope(HOST_LAUNCH_MEDIA_PROVIDER, label, label); + if (i % 2 == 0) scope.success(); + } +} +''' + (temp / "handler.c").write_text(harness, encoding="utf-8") + (temp / "bridge.cpp").write_text(bridge, encoding="utf-8") + include = str(ROOT / "runtime/include") + cls.exe = temp / ("protocol.exe" if os.name == "nt" else "protocol") + for args in ([cc, "-std=c99", "-Wall", "-Wextra", "-Werror", "-I", include, + "-c", str(temp / "handler.c"), "-o", str(temp / "handler.o")], + [cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread", + "-I", include, str(temp / "bridge.cpp"), str(temp / "handler.o"), + "-o", str(cls.exe)]): + subprocess.run(args, check=True, capture_output=True, text=True) + + def query(self, count=None, seeded=300): + request = {"cmd": "host_launch_timings"} + if count is not None: + request["count"] = count + output = subprocess.check_output([str(self.exe), json.dumps(request), str(seeded)], text=True) + return json.loads(output) + + def test_wrapped_maximum_labels_and_default_count(self): + result = self.query() + self.assertEqual((result["capacity"], result["total"], result["overwritten"]), (256, 300, 44)) + self.assertTrue(result["nested_durations_overlap"]) + self.assertEqual([e["seq"] for e in result["entries"]], list(range(45, 301))) + for e in result["entries"]: + self.assertEqual(e["stage"], "media_provider") + self.assertEqual(e["package"], '"\\\x01' * 21) + self.assertEqual(e["feature"], e["package"]) + self.assertEqual(e["labels_truncated"], 0) + self.assertEqual(e["ok"], int(e["seq"] % 2 == 1)) + + def test_count_clamping(self): + for count, expected in ((0, 1), (-10, 1), (1, 1), (3, 3), (256, 256), (1000, 256)): + result = self.query(count) + self.assertEqual(len(result["entries"]), expected) + self.assertEqual(result["entries"][-1]["seq"], 300) + + def test_empty_ring(self): + result = self.query(seeded=0) + self.assertEqual((result["total"], result["overwritten"], result["entries"]), (0, 0, [])) + + +if __name__ == "__main__": + suite = unittest.defaultTestLoader.loadTestsFromTestCase(HostLaunchProtocol) + result = unittest.TextTestRunner().run(suite) + raise SystemExit(77 if result.skipped else 0 if result.wasSuccessful() else 1)