From 727f8db50d109cedaad26dc75d76ad84b0a5d5c7 Mon Sep 17 00:00:00 2001 From: Yves Date: Sun, 4 Oct 2026 05:54:03 +0800 Subject: [PATCH 1/8] debug: add guest MIPS disassembly to the TCP server Adapted from the original feature commits, retaining their author credit: [85c47e10](https://github.com/FractalEngineer/psxrecomp/commit/85c47e10a2fa6e112205e0e953a89ba120c4b139) [d58db909](https://github.com/FractalEngineer/psxrecomp/commit/d58db9092a57cc692d0c9d936f5003fb4b3e0977) [c2ed6e55](https://github.com/FractalEngineer/psxrecomp/commit/c2ed6e558f37212c36aedfdfa2c90863883f0aff) Source scope and current-master validation: docs/internal/upstream/tcp-guest-disassembly.md Co-authored-by: Yves --- docs/TCP_COMMANDS.md | 9 +- .../upstream/tcp-guest-disassembly.md | 43 ++++++ runtime/CMakeLists.txt | 4 + runtime/include/psx_disasm.h | 19 +++ runtime/runtime.cmake | 2 + runtime/src/debug_server.c | 37 +++++ runtime/src/disasm_shim.cpp | 134 ++++++++++++++++++ runtime/tests/test_disasm.cpp | 46 ++++++ 8 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 docs/internal/upstream/tcp-guest-disassembly.md create mode 100644 runtime/include/psx_disasm.h create mode 100644 runtime/src/disasm_shim.cpp create mode 100644 runtime/tests/test_disasm.cpp diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 4f45d200..168832e9 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -430,9 +430,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**323 commands registered** — 310 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**324 commands registered** — 311 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -61 of 323 have prose above; **262 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. +62 of 324 have prose above; **262 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. @@ -504,6 +504,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `dirty_insn_log` | ✓ | | | | `dirty_ram_stats` | ✓ | | ✓ | | `dirty_ram_unsupported` | ✓ | | | +| `disasm` | ✓ | | | | `disc_select` | ✓ | | | | `disp_ring` | ✓ | | | | `dispatch_check` | ✓ | | | @@ -631,7 +632,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `pc_probe_arm` | ✓ | | | | `pc_probe_clear` | ✓ | | | | `pc_probe_dump` | ✓ | | | -| `pgxp` | ✓ | | | +| `pgxp` | ✓ | | ✓ | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | @@ -763,3 +764,5 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `xprobe_watch` | ✓ | | | + +`disasm addr= count=<1..256>` inspects aligned guest instructions; default count is 16. Resolved targets use the guest PC. Unknown words are shown verbatim. diff --git a/docs/internal/upstream/tcp-guest-disassembly.md b/docs/internal/upstream/tcp-guest-disassembly.md new file mode 100644 index 00000000..5401a696 --- /dev/null +++ b/docs/internal/upstream/tcp-guest-disassembly.md @@ -0,0 +1,43 @@ +# TCP guest instruction disassembly: source and validation + +Reconstructed from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp), developed on +`vr-dev` at `9976567eb8e95510ca7eeeeccd9af3a260885de5`. +Exact source commits: + +- [85c47e10](https://github.com/FractalEngineer/psxrecomp/commit/85c47e10a2fa6e112205e0e953a89ba120c4b139) +- [d58db909](https://github.com/FractalEngineer/psxrecomp/commit/d58db9092a57cc692d0c9d936f5003fb4b3e0977) +- [c2ed6e55](https://github.com/FractalEngineer/psxrecomp/commit/c2ed6e558f37212c36aedfdfa2c90863883f0aff) + +Original author: Yves . The feature commit retains +that exact human author identity. The focused port consolidates the selected +source hunks and corrections. Its commit also cites these sources and carries +the original human contributor's `Co-authored-by: Yves ` trailer. + +Adapted: The C-compatible bridge to the existing MIPS decoder, bounded TCP handler, build linkage and command docs. Formatting was corrected and covered during reconstruction. + +Deliberately excluded: Guest instruction execution/codegen changes, new decoders, game-specific addresses and runtime printf debugging. + +The feature was reconstructed on upstream master +`db62eec15f81c88aef3d6dab51d4e566a7167d23`, retaining current upstream APIs, +renderer/input policy and build integration. Validation after reconstruction: +Disassembly operand/target/coprocessor/buffer regressions, debug-tool ON/OFF server compilation and regenerated TCP index. + +The motivating consumer is [Medal of Honor, SLUS-00974 (NTSC-U)](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442). +Game-specific integration remains in that repository. Consuming this change +requires a deliberate framework pin update in the game repo and a rebuild. +This is a host/runtime/tool change; it requires no BIOS or game C regeneration. + +Historical alpha acceptance used the older framework checkpoint above. It is +separate from current-master branch verification. Fresh game, renderer and +headset checks, where applicable, are recorded in the PR validation notes; +uncertain hardware/runtime behavior must not be inferred from a clean link. + +## Current-master integration validation + +Windows, GCC 13.2, Ninja, Release. Runtime test checks remain active with NDEBUG enabled. + +Release disassembly tests passed for arithmetic, multiply/divide, shifts, branch/jump targets, immediates, COP0/COP2, unknown words and bounded buffers. Checks execute with NDEBUG enabled. Full runtime builds passed with debug tools enabled and disabled; the generated command index passes. + +Integration: [Medal of Honor, SLUS-00974 NTSC-U](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442), built against the corrected framework stack with `PSXRECOMP_ROOT`. Bundled OpenBIOS LLE cold boot, menu navigation, mission gameplay, fresh savestate creation/loading and post-load input/frame advancement were checked with screenshots. An older alpha slot-5 savestate was refused; compatibility with historical states is not established. Memory-card round trips, attract-demo completion, other games and fresh headset visuals were not tested. + +The registered recompiler suite reports 132 passes, four failures and three disabled tests. The four failures reproduce on unchanged upstream master in this Windows toolchain: `portable_settings_paths_test`, `optional_netplay_auth`, `compile_overlays_static_tail` and `aot_overlay_discovery`. The introduced BIOS-fingerprint and GL-guard failures are fixed. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c2b4e9a3..bcf59e02 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,10 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(disasm_test tests/test_disasm.cpp src/disasm_shim.cpp + ../recompiler/src/mips_decoder.cpp) + target_include_directories(disasm_test PRIVATE include ../recompiler/include) + add_test(NAME disasm_test COMMAND disasm_test) # Netplay: which BIOS a match boots, and its wire helpers. add_executable(netplay_bios_settle_test tests/test_netplay_bios_settle.c diff --git a/runtime/include/psx_disasm.h b/runtime/include/psx_disasm.h new file mode 100644 index 00000000..b496dfdf --- /dev/null +++ b/runtime/include/psx_disasm.h @@ -0,0 +1,19 @@ +#ifndef PSX_DISASM_H +#define PSX_DISASM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Format one MIPS R3000 instruction as text into out (NUL-terminated). + * addr is used to resolve branch/jump targets. Returns the number of + * characters written (excluding the NUL), or 0 if out/cap are unusable. */ +int psx_disasm_one(uint32_t word, uint32_t addr, char* out, int cap); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_DISASM_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index f2beb82b..2c4ba082 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -446,6 +446,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/netplay_exit_reason.c ${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp ${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp + ${PSXRECOMP_ROOT}/recompiler/src/mips_decoder.cpp + ${PSXRECOMP_ROOT}/runtime/src/disasm_shim.cpp # (sljit Tier-2 in-process JIT backend removed 2026-07-15 — was disabled by # default since 2026-06-25; gaps fall to the interpreter, gcc/tcc unaffected.) ) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dffcc360..58dd1c6e 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -27,6 +27,7 @@ #include "overlay_backend.h" #include "cpu_state.h" #include "pgxp.h" +#include "psx_disasm.h" #include "dma.h" #include "gpu.h" #include "gpu_render.h" /* gr_scale + gr_render_display_hires (screenshot_hires) */ @@ -5182,6 +5183,41 @@ static void handle_get_registers(int id, const char *json) free(buf); } +/* disasm: disassemble guest instructions. {"cmd":"disasm","addr":"0x8001121C", + * "count":32}. Reads live guest RAM via psx_read_word, so it works on EXE code + * and on RAM-installed/overlay code alike. Uses the recompiler's MIPS decoder. */ +static void handle_disasm(int id, const char *json) +{ + char addr_str[32]; + if (!json_get_str(json, "addr", addr_str, sizeof(addr_str))) { + send_err(id, "missing addr"); return; + } + uint32_t addr = hex_to_u32(addr_str) & ~3u; + int count = json_get_int(json, "count", 16); + if (count < 1) count = 1; + if (count > 256) count = 256; + + size_t bufsz = 512u + (size_t)count * 160u; + char *out = (char *)malloc(bufsz); + if (!out) { send_err(id, "oom"); return; } + int pos = snprintf(out, bufsz, + "{\"id\":%d,\"ok\":true,\"addr\":\"0x%08X\",\"count\":%d,\"lines\":[", + id, addr, count); + char text[128]; + for (int i = 0; i < count; i++) { + uint32_t a = addr + (uint32_t)i * 4u; + uint32_t w = psx_read_word(a); + text[0] = '\0'; + (void)psx_disasm_one(w, a, text, (int)sizeof(text)); + pos += snprintf(out + pos, bufsz - (size_t)pos, + "%s{\"addr\":\"0x%08X\",\"word\":\"0x%08X\",\"text\":\"%s\"}", + i ? "," : "", a, w, text); + } + pos += snprintf(out + pos, bufsz - (size_t)pos, "]}"); + debug_server_send_line(out); + free(out); +} + static void handle_read_ram(int id, const char *json) { char addr_str[32]; @@ -14364,6 +14400,7 @@ static const CmdEntry s_commands[] = { { "c0_history", handle_c0_history }, { "capture_quads", handle_capture_quads }, { "get_quads", handle_get_quads }, + { "disasm", handle_disasm }, { "gte_state", handle_gte_state }, { "gte_ring_dump", handle_gte_ring_dump }, { "gte_intpl_dump", handle_gte_intpl_dump }, diff --git a/runtime/src/disasm_shim.cpp b/runtime/src/disasm_shim.cpp new file mode 100644 index 00000000..46a7fdfc --- /dev/null +++ b/runtime/src/disasm_shim.cpp @@ -0,0 +1,134 @@ +#include "psx_disasm.h" +#include "mips_decoder.h" + +#include +#include + +/* C-linkage bridge so the C debug server can use the C++ MIPS decoder that the + * recompiler already ships (recompiler/src/mips_decoder.cpp). */ + +/* GTE data registers (COP2 rd index) — psx-spx. */ +static const char* const kCop2Data[32] = { + "VXY0", "VZ0", "VXY1", "VZ1", "VXY2", "VZ2", "RGB", "OTZ", + "IR0", "IR1", "IR2", "IR3", "SXY0", "SXY1", "SXY2", "SXYP", + "SZ0", "SZ1", "SZ2", "SZ3", "RGB0", "RGB1", "RGB2", "RES1", + "MAC0", "MAC1", "MAC2", "MAC3", "IRGB", "ORGB", "LZCS", "LZCR" +}; + +/* GTE control registers (COP2 rd index) — psx-spx. */ +static const char* const kCop2Ctrl[32] = { + "RT11RT12", "RT13RT21", "RT22RT23", "RT31RT32", "RT33", "TRX", "TRY", "TRZ", + "L11L12", "L13L21", "L22L23", "L31L32", "L33", "RBK", "GBK", "BBK", + "LR1LR2", "LR3LG1", "LG2LG3", "LB1LB2", "LB3", "RFC", "GFC", "BFC", + "OFX", "OFY", "H", "DQA", "DQB", "ZSF3", "ZSF4", "FLAG" +}; + +static const char* gte_cmd_name(uint32_t raw) { + switch (raw & 0x3Fu) { + case 0x01: return "RTPS"; + case 0x06: return "NCLIP"; + case 0x0C: return "OP"; + case 0x10: return "DPCS"; + case 0x11: return "INTPL"; + case 0x12: return "MVMVA"; + case 0x13: return "NCDS"; + case 0x14: return "CDP"; + case 0x16: return "NCDT"; + case 0x1B: return "NCCS"; + case 0x1C: return "CC"; + case 0x1E: return "NCS"; + case 0x20: return "NCT"; + case 0x28: return "SQR"; + case 0x29: return "DCPL"; + case 0x2A: return "DPCT"; + case 0x2D: return "AVSZ3"; + case 0x2E: return "AVSZ4"; + case 0x30: return "RTPT"; + case 0x3D: return "GPF"; + case 0x3E: return "GPL"; + case 0x3F: return "NCCT"; + default: return "GTE"; + } +} + +extern "C" int psx_disasm_one(uint32_t word, uint32_t addr, char* out, int cap) { + if (!out || cap <= 0) return 0; + using PSXRecomp::DecodedInstruction; + using PSXRecomp::InstrFormat; + using PSXRecomp::MipsDecoder; + + DecodedInstruction d = MipsDecoder::decode(word, addr); + const char* m = d.mnemonic ? d.mnemonic : "???"; + auto rn = [](uint8_t r) { return MipsDecoder::register_name(r); }; + uint8_t cop = d.rd & 0x1F; + + int n; + if (d.format == InstrFormat::UNKNOWN) { + n = std::snprintf(out, (size_t)cap, ".word 0x%08X", word); + } else if (std::strcmp(m, "JR") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s", m, rn(d.rs)); + } else if (std::strcmp(m, "JALR") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s", m, rn(d.rd), rn(d.rs)); + } else if (std::strcmp(m, "SLL") == 0 || std::strcmp(m, "SRL") == 0 || + std::strcmp(m, "SRA") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, %d", m, rn(d.rd), rn(d.rt), + d.shamt); + } else if (std::strcmp(m, "MFHI") == 0 || std::strcmp(m, "MFLO") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s", m, rn(d.rd)); + } else if (std::strcmp(m, "MTHI") == 0 || std::strcmp(m, "MTLO") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s", m, rn(d.rs)); + } else if (std::strcmp(m, "SYSCALL") == 0 || std::strcmp(m, "BREAK") == 0) { + n = std::snprintf(out, (size_t)cap, "%s", m); + } else if (std::strcmp(m, "MULT") == 0 || std::strcmp(m, "MULTU") == 0 || + std::strcmp(m, "DIV") == 0 || std::strcmp(m, "DIVU") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s", m, rn(d.rs), rn(d.rt)); + } else if (std::strcmp(m, "SLLV") == 0 || std::strcmp(m, "SRLV") == 0 || + std::strcmp(m, "SRAV") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, %s", m, rn(d.rd), rn(d.rt), rn(d.rs)); + } else if (d.format == InstrFormat::COP0) { + if (std::strcmp(m, "MFC0") == 0 || std::strcmp(m, "MTC0") == 0) + n = std::snprintf(out, (size_t)cap, "%-8s %s, $%u", m, rn(d.rt), (unsigned)cop); + else if (std::strcmp(m, "RFE") == 0) + n = std::snprintf(out, (size_t)cap, "%s", m); + else + n = std::snprintf(out, (size_t)cap, ".word 0x%08X", word); + } else if (d.format == InstrFormat::COP2) { + if (std::strcmp(m, "MFC2") == 0 || std::strcmp(m, "MTC2") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, $%s", m, rn(d.rt), + kCop2Data[cop]); + } else if (std::strcmp(m, "CFC2") == 0 || std::strcmp(m, "CTC2") == 0) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, $%s", m, rn(d.rt), + kCop2Ctrl[cop]); + } else if (d.rs & 0x10) { /* GTE command word */ + n = std::snprintf(out, (size_t)cap, "%-8s 0x%07X", gte_cmd_name(d.raw), + d.raw & 0x1FFFFFFu); + } else { + n = std::snprintf(out, (size_t)cap, ".word 0x%08X", word); + } + } else if (d.format == InstrFormat::J) { + n = std::snprintf(out, (size_t)cap, "%-8s 0x%08X", m, d.jump_target); + } else if (d.is_branch && d.opcode != 0x04 && d.opcode != 0x05) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, 0x%08X", m, rn(d.rs), d.branch_target); + } else if (d.is_branch) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, 0x%08X", m, rn(d.rs), + rn(d.rt), d.branch_target); + } else if (d.opcode == 0x0F) { /* LUI */ + n = std::snprintf(out, (size_t)cap, "%-8s %s, 0x%04X", m, rn(d.rt), + (unsigned)d.uimm16); + } else if (d.is_load || d.is_store) { + const char* reg = (d.opcode == 0x32 || d.opcode == 0x3A) ? kCop2Data[d.rt & 31] : rn(d.rt); + const char* prefix = (d.opcode == 0x32 || d.opcode == 0x3A) ? "$" : ""; + n = std::snprintf(out, (size_t)cap, "%-8s %s%s, %d(%s)", m, prefix, reg, + (int)d.imm16, rn(d.rs)); + } else if (d.format == InstrFormat::R || d.format == InstrFormat::SPECIAL) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, %s", m, rn(d.rd), rn(d.rs), + rn(d.rt)); + } else if (d.opcode >= 0x0C && d.opcode <= 0x0E) { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, 0x%04X", m, rn(d.rt), rn(d.rs), (unsigned)d.uimm16); + } else { + n = std::snprintf(out, (size_t)cap, "%-8s %s, %s, %d", m, rn(d.rt), rn(d.rs), + (int)d.imm16); + } + if (n < 0) { out[0] = '\0'; return 0; } + return (n < cap) ? n : (cap - 1); +} diff --git a/runtime/tests/test_disasm.cpp b/runtime/tests/test_disasm.cpp new file mode 100644 index 00000000..69bc463e --- /dev/null +++ b/runtime/tests/test_disasm.cpp @@ -0,0 +1,46 @@ +#include "psx_disasm.h" +#include +#include +#include +static void require(bool condition, const char* expression, int line) { + if (!condition) { + std::fprintf(stderr, "FAIL line %d: %s\n", line, expression); + std::exit(EXIT_FAILURE); + } +} +#define CHECK(expr) require((expr), #expr, __LINE__) +static void check(unsigned word, unsigned pc, const char* expected) { + char out[128]; + int n=psx_disasm_one(word, pc, out, sizeof(out)); + if (std::strcmp(out,expected)) { + std::fprintf(stderr,"%08X: expected [%s], got [%s]\n",word,expected,out); + CHECK(false); + } + CHECK(n == (int)std::strlen(expected)); +} +int main() { + check(0x012A4020,0,"ADD $t0, $t1, $t2"); + check(0x012A0018,0,"MULT $t1, $t2"); + check(0x012A001B,0,"DIVU $t1, $t2"); + check(0x012A4004,0,"SLLV $t0, $t2, $t1"); + check(0x0521FFFF,0x80001000,"BGEZ $t1, 0x80001000"); + check(0x1920FFFE,0x80001000,"BLEZ $t1, 0x80000FFC"); + check(0x112AFFFE,0x80001000,"BEQ $t1, $t2, 0x80000FFC"); + check(0x08000000,0x8FFFFFFC,"J 0x90000000"); + check(0x3128FFFF,0,"ANDI $t0, $t1, 0xFFFF"); + check(0x2528FFFF,0,"ADDIU $t0, $t1, -1"); + check(0x8D28FFF8,0,"LW $t0, -8($t1)"); + check(0xC923FFF8,0,"LWC2 $VZ1, -8($t1)"); + check(0x40086000,0,"MFC0 $t0, $12"); + check(0x42000010,0,"RFE"); + check(0x48086800,0,"MFC2 $t0, $SXY1"); + check(0x4848D000,0,"CFC2 $t0, $H"); + check(0x4A080001,0,"RTPS 0x0080001"); + check(0x48200001,0,".word 0x48200001"); + check(0xFC000000,0,".word 0xFC000000"); + char tiny[4]={'x','x','x','x'}; + CHECK(psx_disasm_one(0,0,tiny,4)==3 && tiny[3]==0); + CHECK(psx_disasm_one(0,0,tiny,1)==0 && tiny[0]==0); + CHECK(psx_disasm_one(0,0,nullptr,4)==0); + tiny[0]='x'; CHECK(psx_disasm_one(0,0,tiny,0)==0 && tiny[0]=='x'); +} From ecd4255cb59eb31d185c94061ffba6682cb1492e Mon Sep 17 00:00:00 2001 From: Yves Date: Sun, 4 Oct 2026 05:54:03 +0800 Subject: [PATCH 2/8] debug: filter recorded write traces by producer PC Adapted from the original feature commits, retaining their author credit: [734977c9](https://github.com/FractalEngineer/psxrecomp/commit/734977c93e80789e9210a262daa35a88f41c40d4) Source scope and current-master validation: docs/internal/upstream/wtrace-producer-pc-filter.md Co-authored-by: Yves --- docs/TCP_COMMANDS.md | 6 +- .../upstream/wtrace-producer-pc-filter.md | 41 +++++++ runtime/CMakeLists.txt | 3 + runtime/src/debug_server.c | 15 ++- runtime/tests/test_wtrace_dump.py | 106 ++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 docs/internal/upstream/wtrace-producer-pc-filter.md create mode 100644 runtime/tests/test_wtrace_dump.py diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 4f45d200..97f7ee1d 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -432,7 +432,7 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i **323 commands registered** — 310 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -61 of 323 have prose above; **262 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. +62 of 323 have prose above; **261 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. @@ -631,7 +631,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `pc_probe_arm` | ✓ | | | | `pc_probe_clear` | ✓ | | | | `pc_probe_dump` | ✓ | | | -| `pgxp` | ✓ | | | +| `pgxp` | ✓ | | ✓ | | `phase_hot` | ✓ | | | | `phase_profile` | ✓ | | | | `ping` | ✓ | ✓ | ✓ | @@ -763,3 +763,5 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `xprobe_watch` | ✓ | | | + +`wtrace_dump pc_lo= pc_hi=` filters the full recorded producer PC with inclusive lower and exclusive upper bounds, before limiting results. Omitted bounds default to 0 and 0xFFFFFFFF; DMA entries use their recorded initiator. Address/frame/order filters still apply. diff --git a/docs/internal/upstream/wtrace-producer-pc-filter.md b/docs/internal/upstream/wtrace-producer-pc-filter.md new file mode 100644 index 00000000..b0173c86 --- /dev/null +++ b/docs/internal/upstream/wtrace-producer-pc-filter.md @@ -0,0 +1,41 @@ +# Write-trace producer-PC filters: source and validation + +Reconstructed from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp), developed on +`vr-dev` at `9976567eb8e95510ca7eeeeccd9af3a260885de5`. +Exact source commits: + +- [734977c9](https://github.com/FractalEngineer/psxrecomp/commit/734977c93e80789e9210a262daa35a88f41c40d4) + +Original author: Yves . The feature commit retains +that exact human author identity. The focused port consolidates the selected +source hunks and corrections. Its commit also cites these sources and carries +the original human contributor's `Co-authored-by: Yves ` trailer. + +Adapted: Only full-PC low/high query filters and response bounds; recording is unchanged. + +Deliberately excluded: The same source commit's OpenXR hand snapshots and pose math; no game memory-map constants. + +The feature was reconstructed on upstream master +`db62eec15f81c88aef3d6dab51d4e566a7167d23`, retaining current upstream APIs, +renderer/input policy and build integration. Validation after reconstruction: +Six production-handler wrapped-ring regressions, debug-tool ON/OFF compilation and regenerated TCP index. + +The motivating consumer is [Medal of Honor, SLUS-00974 (NTSC-U)](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442). +Game-specific integration remains in that repository. Consuming this change +requires a deliberate framework pin update in the game repo and a rebuild. +This is a host/runtime/tool change; it requires no BIOS or game C regeneration. + +Historical alpha acceptance used the older framework checkpoint above. It is +separate from current-master branch verification. Fresh game, renderer and +headset checks, where applicable, are recorded in the PR validation notes; +uncertain hardware/runtime behavior must not be inferred from a clean link. + +## Current-master integration validation + +Windows, GCC 13.2, Ninja, Release. Runtime test checks remain active with NDEBUG enabled. + +Six production-handler regressions passed against a wrapped trace ring, covering full PC segments, inclusive/exclusive bounds, filtering before count, combined filters, DMA metadata and response bounds. Full debug-enabled/disabled runtime builds and the generated command index pass. + +Integration: [Medal of Honor, SLUS-00974 NTSC-U](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/e323885958c2ae0b0df847bed6175f9e76524442), built against the corrected framework stack with `PSXRECOMP_ROOT`. Bundled OpenBIOS LLE cold boot, menu navigation, mission gameplay, fresh savestate creation/loading and post-load input/frame advancement were checked with screenshots. An older alpha slot-5 savestate was refused; compatibility with historical states is not established. Memory-card round trips, attract-demo completion, other games and fresh headset visuals were not tested. + +The registered recompiler suite reports 132 passes, four failures and three disabled tests. The four failures reproduce on unchanged upstream master in this Windows toolchain: `portable_settings_paths_test`, `optional_netplay_auth`, `compile_overlays_static_tail` and `aot_overlay_discovery`. The introduced BIOS-fingerprint and GL-guard failures are fixed. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c2b4e9a3..8cd4120d 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -95,6 +95,9 @@ 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 wtrace_dump_test COMMAND ${CMAKE_COMMAND} -E env + "CC=${CMAKE_C_COMPILER}" "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_wtrace_dump.py") add_test(NAME interp_mod_entry_guards_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_interp_mod_entry_guards.py") add_test(NAME mirror_pc_preserved_guards_test COMMAND "${Python3_EXECUTABLE}" diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dffcc360..1f1ff7ca 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -11266,6 +11266,15 @@ static void handle_wtrace_dump(int id, const char *json) if (json_get_str(json, "addr_hi", hi_str, sizeof(hi_str))) filter_hi = hex_to_u32(hi_str) & 0x1FFFFFFFu; + /* Filter the recorded producer PC, including its segment. DMA entries use + * the initiator PC already stored by wtrace_fill_entry. Post-hoc only: + * recording, fingerprints and guest execution are unchanged. */ + uint32_t pc_lo = 0, pc_hi = 0xFFFFFFFFu; + if (json_get_str(json, "pc_lo", lo_str, sizeof(lo_str))) + pc_lo = hex_to_u32(lo_str); + if (json_get_str(json, "pc_hi", hi_str, sizeof(hi_str))) + pc_hi = hex_to_u32(hi_str); + /* Optional frame-window filter — the "query the ring for the window of * interest" primitive. Lets a caller reach entries in the MIDDLE of a deep, * high-traffic ring (which oldest-N / newest-N paging cannot). -1 = unbounded. */ @@ -11289,8 +11298,9 @@ static void handle_wtrace_dump(int id, const char *json) size_t pos = 0; uint32_t emitted = 0; pos += snprintf(buf + pos, BUF_SZ - pos, - "{\"id\":%d,\"ok\":true,\"total\":%llu,\"available\":%u,\"entries\":[", - id, (unsigned long long)total, avail); + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"available\":%u," + "\"pc_lo\":\"0x%08X\",\"pc_hi\":\"0x%08X\",\"entries\":[", + id, (unsigned long long)total, avail, pc_lo, pc_hi); for (uint32_t i = 0; i < avail && emitted < MAX_OUT && pos < BUF_SZ - 256; i++) { uint32_t idx; if (newest_first) { @@ -11301,6 +11311,7 @@ static void handle_wtrace_dump(int id, const char *json) } WriteTraceEntry *e = &s_wtrace[idx]; if (e->addr < filter_lo || e->addr >= filter_hi) continue; + if (e->pc < pc_lo || e->pc >= pc_hi) continue; if (frame_lo >= 0 && (int)e->frame < frame_lo) continue; if (frame_hi >= 0 && (int)e->frame > frame_hi) continue; pos += snprintf(buf + pos, BUF_SZ - pos, diff --git a/runtime/tests/test_wtrace_dump.py b/runtime/tests/test_wtrace_dump.py new file mode 100644 index 00000000..1f75f268 --- /dev/null +++ b/runtime/tests/test_wtrace_dump.py @@ -0,0 +1,106 @@ +"""Execute the production trace-query handler against a wrapped fixture ring. + +Compile only the handler and its real JSON helpers, replacing the transport +with stdout. This needs a C compiler, but no game image, SDL or live server. +""" +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tempfile +import unittest + + +class WriteTraceQuery(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + cls.addClassCleanup(cls.temp.cleanup) + source = (Path(__file__).parents[1] / "src/debug_server.c").read_text(encoding="utf-8") + + def function(name): + match = re.search(r"static [^;{}]+\b" + name + r"\([^;{}]*\)\s*\{", source) + if not match: + raise AssertionError(f"production function missing: {name}") + end = source.index("\n}", match.start()) + 2 + return source[match.start():end] + + end = source.index("} WriteTraceEntry;") + len("} WriteTraceEntry;") + start = source.rfind("typedef struct {", 0, end) + harness = """#include +#include +#include +#include +#define WRITE_TRACE_CAP 8 +""" + source[start:end] + """ +static WriteTraceEntry ring[WRITE_TRACE_CAP], *s_wtrace=ring; +static uint64_t s_wtrace_seq=10; +static uint32_t s_wtrace_head=2; +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_str", "json_get_int", "hex_to_u32", "handle_wtrace_dump"): + harness += function(name) + "\n" + harness += """int main(int argc, char **argv) { + const uint32_t pcs[8]={0x80001000,0x80001004,0xA0001000,0x80001008, + 0x8000100C,0x80001010,0x80001004,0x00001004}; + for (int seq=2; seq<10; ++seq) { + WriteTraceEntry *e=&ring[seq%WRITE_TRACE_CAP]; + e->seq=seq; e->pc=pcs[seq-2]; e->addr=0x1000+(seq-2)*4; + e->frame=10+(seq-2)/2; e->width=4; e->dma_ch=seq==5?2:-1; + } + handle_wtrace_dump(7,argc>1?argv[1]:"{}"); + return 0; +} +""" + path = Path(cls.temp.name) + (path / "query.c").write_text(harness, encoding="utf-8", newline="\n") + cls.exe = path / ("query.exe" if os.name == "nt" else "query") + compiler_setting = os.environ.get("CC", "cc") + compiler = ([compiler_setting] if shutil.which(compiler_setting) else + shlex.split(compiler_setting, posix=os.name != "nt")) + if not shutil.which(compiler[0]): + raise RuntimeError("Set CC to a C compiler to run the trace-query regression") + subprocess.run([*compiler, str(path / "query.c"), "-o", str(cls.exe)], check=True) + + def query(self, **params): + result = subprocess.check_output([str(self.exe), json.dumps(params)], text=True) + reply = json.loads(result) + self.assertEqual(reply["emitted"], len(reply["entries"])) + return reply + + def sequences(self, **params): + return [e["seq"] for e in self.query(**params)["entries"]] + + def test_default_wrapped_order(self): + self.assertEqual(self.sequences(), list(range(2, 10))) + + def test_full_pc_and_exclusive_upper(self): + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C"), [3, 5, 8]) + + def test_newest_and_count_after_filter(self): + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", newest=1, count=1), [8]) + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", count=1), [3]) + + def test_combined_address_frame_filters(self): + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", + addr_lo="80001008", addr_hi="80001018", + frame_lo=11, frame_hi=12), [5]) + + def test_empty_ranges(self): + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="80001004"), []) + self.assertEqual(self.sequences(pc_hi="0"), []) + + def test_dma_recorded_initiator_and_response_bounds(self): + reply = self.query(pc_lo="80001008", pc_hi="8000100C") + self.assertEqual(reply["pc_lo"], "0x80001008") + self.assertEqual(reply["pc_hi"], "0x8000100C") + self.assertEqual(reply["entries"][0]["dma_ch"], 2) + self.assertEqual(reply["entries"][0]["seq"], 5) + + +if __name__ == "__main__": + unittest.main() From 4c6b3748cad1220664e6893326b740308c621326 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:42:45 -0700 Subject: [PATCH 3/8] debug: segment-mask wtrace PC filter; DMA entries never inherit CPU store PC Also: wtrace_dump_test skips (exit 77) without a C compiler, supports cl, brace-matched extraction. Co-Authored-By: Claude Sonnet 5.5 --- docs/TCP_COMMANDS.md | 2 +- runtime/src/debug_server.c | 28 +++++--- runtime/tests/test_wtrace_dump.py | 108 ++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 97f7ee1d..418099f3 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -764,4 +764,4 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc -`wtrace_dump pc_lo= pc_hi=` filters the full recorded producer PC with inclusive lower and exclusive upper bounds, before limiting results. Omitted bounds default to 0 and 0xFFFFFFFF; DMA entries use their recorded initiator. Address/frame/order filters still apply. +`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. diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 16391f8e..19fd432c 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9709,7 +9709,7 @@ static void wtrace_fill_entry(WriteTraceEntry *e, uint64_t seq, * instead of the stale last-CPU-store PC (which is meaningless mid-DMA). */ if (g_dma_exec_depth > 0) { e->dma_ch = (int8_t)g_dma_cur_ch; - if (g_dma_initiator_pc) e->pc = g_dma_initiator_pc; + e->pc = g_dma_initiator_pc; /* 0 = unknown; never a stale CPU store PC */ } else { e->dma_ch = -1; } @@ -11316,14 +11316,21 @@ static void handle_wtrace_dump(int id, const char *json) if (json_get_str(json, "addr_hi", hi_str, sizeof(hi_str))) filter_hi = hex_to_u32(hi_str) & 0x1FFFFFFFu; - /* Filter the recorded producer PC, including its segment. DMA entries use - * the initiator PC already stored by wtrace_fill_entry. Post-hoc only: - * recording, fingerprints and guest execution are unchanged. */ + /* Filter the recorded producer PC, segment-masked like the address filter + * (KSEG0/KSEG1/KUSEG alias). DMA entries use the initiator PC stored by + * wtrace_fill_entry; an unknown (0) initiator matches only when no PC + * bound is given. Post-hoc only: recording, fingerprints and guest + * execution are unchanged. pc_lo/pc_hi echo the raw request. */ uint32_t pc_lo = 0, pc_hi = 0xFFFFFFFFu; - if (json_get_str(json, "pc_lo", lo_str, sizeof(lo_str))) - pc_lo = hex_to_u32(lo_str); - if (json_get_str(json, "pc_hi", hi_str, sizeof(hi_str))) - pc_hi = hex_to_u32(hi_str); + int pc_filtered = 0; + /* Default upper bound sits above every masked PC so no entry is excluded. */ + uint32_t pc_lo_m = 0, pc_hi_m = 0x20000000u; + if (json_get_str(json, "pc_lo", lo_str, sizeof(lo_str))) { + pc_lo = hex_to_u32(lo_str); pc_lo_m = pc_lo & 0x1FFFFFFFu; pc_filtered = 1; + } + if (json_get_str(json, "pc_hi", hi_str, sizeof(hi_str))) { + pc_hi = hex_to_u32(hi_str); pc_hi_m = pc_hi & 0x1FFFFFFFu; pc_filtered = 1; + } /* Optional frame-window filter — the "query the ring for the window of * interest" primitive. Lets a caller reach entries in the MIDDLE of a deep, @@ -11361,7 +11368,10 @@ static void handle_wtrace_dump(int id, const char *json) } WriteTraceEntry *e = &s_wtrace[idx]; if (e->addr < filter_lo || e->addr >= filter_hi) continue; - if (e->pc < pc_lo || e->pc >= pc_hi) continue; + if (pc_filtered) { + uint32_t epc = e->pc & 0x1FFFFFFFu; + if (e->pc == 0 || epc < pc_lo_m || epc >= pc_hi_m) continue; + } if (frame_lo >= 0 && (int)e->frame < frame_lo) continue; if (frame_hi >= 0 && (int)e->frame > frame_hi) continue; pos += snprintf(buf + pos, BUF_SZ - pos, diff --git a/runtime/tests/test_wtrace_dump.py b/runtime/tests/test_wtrace_dump.py index 1f75f268..50cb49f2 100644 --- a/runtime/tests/test_wtrace_dump.py +++ b/runtime/tests/test_wtrace_dump.py @@ -2,6 +2,7 @@ Compile only the handler and its real JSON helpers, replacing the transport with stdout. This needs a C compiler, but no game image, SDL or live server. +Exits 77 (ctest SKIP_RETURN_CODE) when no C compiler is available. """ import json import os @@ -10,13 +11,51 @@ import re import shlex import shutil import subprocess +import sys import tempfile import unittest +SKIP_EXIT = 77 + + +def matching_brace(source, open_index): + """Index of the brace closing the one at open_index (skips strings/comments).""" + depth, i, n = 0, open_index, len(source) + while i < n: + c = source[i] + if source.startswith("/*", i): + i = source.index("*/", i + 2) + 1 + elif source.startswith("//", i): + i = source.index("\n", i) + elif c in "\"'": + i += 1 + while source[i] != c: + i += 2 if source[i] == "\\" else 1 + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return i + i += 1 + raise AssertionError("unbalanced braces") + + +def find_compiler(): + """argv prefix for the C compiler named by CC (default cc), or None.""" + setting = os.environ.get("CC", "cc") + if shutil.which(setting): + return [setting] + parts = shlex.split(setting, posix=os.name != "nt") + return parts if parts and shutil.which(parts[0]) else None + class WriteTraceQuery(unittest.TestCase): @classmethod def setUpClass(cls): + compiler = find_compiler() + if compiler is None: + raise unittest.SkipTest("no C compiler (set CC)") cls.temp = tempfile.TemporaryDirectory() cls.addClassCleanup(cls.temp.cleanup) source = (Path(__file__).parents[1] / "src/debug_server.c").read_text(encoding="utf-8") @@ -25,8 +64,7 @@ class WriteTraceQuery(unittest.TestCase): match = re.search(r"static [^;{}]+\b" + name + r"\([^;{}]*\)\s*\{", source) if not match: raise AssertionError(f"production function missing: {name}") - end = source.index("\n}", match.start()) + 2 - return source[match.start():end] + return source[match.start():matching_brace(source, match.end() - 1) + 1] end = source.index("} WriteTraceEntry;") + len("} WriteTraceEntry;") start = source.rfind("typedef struct {", 0, end) @@ -39,18 +77,36 @@ class WriteTraceQuery(unittest.TestCase): static WriteTraceEntry ring[WRITE_TRACE_CAP], *s_wtrace=ring; static uint64_t s_wtrace_seq=10; static uint32_t s_wtrace_head=2; +typedef struct { uint32_t pc; uint32_t gpr[32]; } CPUState; +static CPUState *debug_cpu_ptr = NULL; +static uint32_t s_frame_count = 0; +uint32_t g_debug_current_func_addr = 0; +uint32_t g_debug_last_store_pc = 0; +int g_dma_exec_depth = 0, g_dma_cur_ch = -1; +uint32_t g_dma_initiator_pc = 0; 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_str", "json_get_int", "hex_to_u32", "handle_wtrace_dump"): + for name in ("json_get_str", "json_get_int", "hex_to_u32", "wtrace_fill_entry", + "handle_wtrace_dump"): harness += function(name) + "\n" + # seq 7 is a DMA entry with an unknown (0) initiator PC. harness += """int main(int argc, char **argv) { + if (argc > 4 && !strcmp(argv[1], "fill")) { /* fill */ + WriteTraceEntry e; memset(&e, 0, sizeof e); + g_dma_exec_depth = atoi(argv[2]); g_dma_cur_ch = 2; + g_dma_initiator_pc = (uint32_t)strtoul(argv[3], 0, 16); + g_debug_last_store_pc = (uint32_t)strtoul(argv[4], 0, 16); + wtrace_fill_entry(&e, 1, 0x1000, 0, 1, 4); + printf("{\\"pc\\":%u,\\"dma_ch\\":%d}\\n", e.pc, e.dma_ch); + return 0; + } const uint32_t pcs[8]={0x80001000,0x80001004,0xA0001000,0x80001008, - 0x8000100C,0x80001010,0x80001004,0x00001004}; + 0x8000100C,0x00000000,0x80001004,0x00001004}; for (int seq=2; seq<10; ++seq) { WriteTraceEntry *e=&ring[seq%WRITE_TRACE_CAP]; e->seq=seq; e->pc=pcs[seq-2]; e->addr=0x1000+(seq-2)*4; - e->frame=10+(seq-2)/2; e->width=4; e->dma_ch=seq==5?2:-1; + e->frame=10+(seq-2)/2; e->width=4; e->dma_ch=seq==5?2:(seq==7?3:-1); } handle_wtrace_dump(7,argc>1?argv[1]:"{}"); return 0; @@ -59,12 +115,11 @@ static void send_err(int id, const char *error) { (void)id; (void)error; abort() path = Path(cls.temp.name) (path / "query.c").write_text(harness, encoding="utf-8", newline="\n") cls.exe = path / ("query.exe" if os.name == "nt" else "query") - compiler_setting = os.environ.get("CC", "cc") - compiler = ([compiler_setting] if shutil.which(compiler_setting) else - shlex.split(compiler_setting, posix=os.name != "nt")) - if not shutil.which(compiler[0]): - raise RuntimeError("Set CC to a C compiler to run the trace-query regression") - subprocess.run([*compiler, str(path / "query.c"), "-o", str(cls.exe)], check=True) + if Path(compiler[0]).stem.lower() in ("cl", "clang-cl"): + out = ["/nologo", "/Fe:" + str(cls.exe), "/Fo:" + str(path) + os.sep] + else: + out = ["-o", str(cls.exe)] + subprocess.run([*compiler, str(path / "query.c"), *out], check=True, cwd=path) def query(self, **params): result = subprocess.check_output([str(self.exe), json.dumps(params)], text=True) @@ -75,14 +130,36 @@ static void send_err(int id, const char *error) { (void)id; (void)error; abort() def sequences(self, **params): return [e["seq"] for e in self.query(**params)["entries"]] + def fill(self, depth, initiator, store_pc): + out = subprocess.check_output( + [str(self.exe), "fill", str(depth), initiator, store_pc], text=True) + return json.loads(out) + + def test_fill_entry_producer_pc(self): + self.assertEqual(self.fill(0, "0", "80001234"), {"pc": 0x80001234, "dma_ch": -1}) + self.assertEqual(self.fill(1, "80005000", "80001234"), {"pc": 0x80005000, "dma_ch": 2}) + # Unknown initiator: the DMA entry must not inherit the stale CPU store PC. + self.assertEqual(self.fill(1, "0", "80001234"), {"pc": 0, "dma_ch": 2}) + def test_default_wrapped_order(self): self.assertEqual(self.sequences(), list(range(2, 10))) - def test_full_pc_and_exclusive_upper(self): - self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C"), [3, 5, 8]) + def test_pc_range_exclusive_upper(self): + # seq 9 has KUSEG pc 0x1004, which aliases 0x80001004. + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C"), [3, 5, 8, 9]) + + def test_segments_alias(self): + self.assertEqual(self.sequences(pc_lo="A0001000", pc_hi="A0001004"), [2, 4]) + self.assertEqual(self.sequences(pc_lo="00001000", pc_hi="00001004"), [2, 4]) + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="80001008"), [3, 8, 9]) + + def test_unknown_dma_initiator_never_matches_pc_filter(self): + self.assertIn(7, self.sequences()) + self.assertNotIn(7, self.sequences(pc_lo="0", pc_hi="FFFFFFFF")) + self.assertEqual(self.sequences(pc_hi="80001000"), []) def test_newest_and_count_after_filter(self): - self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", newest=1, count=1), [8]) + self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", newest=1, count=1), [9]) self.assertEqual(self.sequences(pc_lo="80001004", pc_hi="8000100C", count=1), [3]) def test_combined_address_frame_filters(self): @@ -103,4 +180,7 @@ static void send_err(int id, const char *error) { (void)id; (void)error; abort() if __name__ == "__main__": + if find_compiler() is None: + print("SKIP: no C compiler (set CC)") + sys.exit(SKIP_EXIT) unittest.main() From 516bd81d1e20ce3cc985053f9fbc303df623ba27 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:42:46 -0700 Subject: [PATCH 4/8] tests: wtrace_dump_test SKIP_RETURN_CODE 77 Co-Authored-By: Claude Sonnet 5.5 --- runtime/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 9f2be808..cd69ca65 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -115,6 +115,7 @@ if(BUILD_TESTING) 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") + set_tests_properties(wtrace_dump_test PROPERTIES SKIP_RETURN_CODE 77) add_test(NAME counted_mips_relocations_test COMMAND "${Python3_EXECUTABLE}" "${PSXRECOMP_ROOT}/tools/tests/test_counted_mips_relocations.py") add_test(NAME interp_mod_entry_guards_test COMMAND "${Python3_EXECUTABLE}" From a48a4e21c189e12e66062846dcedddd0784b299f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:42:55 -0700 Subject: [PATCH 5/8] docs: regenerate TCP command index Co-Authored-By: Claude Sonnet 5.5 --- docs/TCP_COMMANDS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 418099f3..a42e89d4 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -430,9 +430,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**323 commands registered** — 310 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**324 commands registered** — 311 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -62 of 323 have prose above; **261 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. +62 of 324 have prose above; **262 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. @@ -603,6 +603,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `mmio_clear` | ✓ | | ✓ | | `mmio_dump` | ✓ | | ✓ | | `mmx6_freshfix` | ✓ | | | +| `mod_counters` | ✓ | | | | `overlay_candidates` | ✓ | | | | `overlay_capture_dump` | ✓ | | | | `overlay_cps_probe` | ✓ | | | From 2fd4625d744bd5b13fe6b5c1d19d1a375142128f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:50:02 -0700 Subject: [PATCH 6/8] disasm: print word 0 as bare NOP Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/src/disasm_shim.cpp | 2 ++ runtime/tests/test_disasm.cpp | 1 + 2 files changed, 3 insertions(+) diff --git a/runtime/src/disasm_shim.cpp b/runtime/src/disasm_shim.cpp index 46a7fdfc..159535d4 100644 --- a/runtime/src/disasm_shim.cpp +++ b/runtime/src/disasm_shim.cpp @@ -65,6 +65,8 @@ extern "C" int psx_disasm_one(uint32_t word, uint32_t addr, char* out, int cap) int n; if (d.format == InstrFormat::UNKNOWN) { n = std::snprintf(out, (size_t)cap, ".word 0x%08X", word); + } else if (word == 0u) { + n = std::snprintf(out, (size_t)cap, "NOP"); } else if (std::strcmp(m, "JR") == 0) { n = std::snprintf(out, (size_t)cap, "%-8s %s", m, rn(d.rs)); } else if (std::strcmp(m, "JALR") == 0) { diff --git a/runtime/tests/test_disasm.cpp b/runtime/tests/test_disasm.cpp index 69bc463e..2ba577d0 100644 --- a/runtime/tests/test_disasm.cpp +++ b/runtime/tests/test_disasm.cpp @@ -19,6 +19,7 @@ static void check(unsigned word, unsigned pc, const char* expected) { CHECK(n == (int)std::strlen(expected)); } int main() { + check(0,0,"NOP"); check(0x012A4020,0,"ADD $t0, $t1, $t2"); check(0x012A0018,0,"MULT $t1, $t2"); check(0x012A001B,0,"DIVU $t1, $t2"); From 07aaa6e70010c29e399ee5df4abc650b9b3ec8bb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:50:02 -0700 Subject: [PATCH 7/8] Add side-effect-free psx_peek_word + test Debug reads of RAM/scratchpad/BIOS without MMIO, read hooks or unmapped_fatal. Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/psx_memory.h | 4 +++ runtime/src/memory.c | 22 ++++++++++++++ runtime/tests/psx_ram_runtime_map_stubs.c | 1 + runtime/tests/test_psx_ram_runtime_map.c | 35 +++++++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/runtime/include/psx_memory.h b/runtime/include/psx_memory.h index 07c9a7ae..a370934b 100644 --- a/runtime/include/psx_memory.h +++ b/runtime/include/psx_memory.h @@ -94,6 +94,10 @@ void psx_host_write_word(uint32_t addr, uint32_t val); void psx_host_write_half(uint32_t addr, uint16_t val); void psx_host_write_byte(uint32_t addr, uint8_t val); +/* Side-effect-free aligned word peek (RAM / scratchpad / BIOS ROM only), for + * the debug path. Returns 0 (and leaves *out alone) for anything else. */ +int psx_peek_word(uint32_t addr, uint32_t *out); + #ifdef __cplusplus } #endif diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 60602bd1..9a8307fc 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -164,6 +164,28 @@ static inline uint32_t psx_phys_addr_store(uint32_t addr) { /* Expose RAM pointer for oracle comparison (find_first_divergence). */ uint8_t *memory_get_ram_ptr(void) { return ram; } uint8_t *memory_get_scratchpad_ptr(void) { return scratchpad; } + +/* Side-effect-FREE aligned word peek for the debug/observability path ONLY: + * main RAM (every KUSEG/KSEG0/KSEG1 mirror), scratchpad and BIOS ROM. Never + * touches MMIO, the lockstep/DS read hooks, read watches, or unmapped_fatal. + * Returns 1 and fills *out when addr is backed by one of those; 0 otherwise. */ +int psx_peek_word(uint32_t addr, uint32_t *out) { + uint32_t phys, off; + const uint8_t *p; + if (addr >= 0xC0000000u || (addr & 3u)) return 0; + phys = addr & 0x1FFFFFFFu; + if (psx_ram_resolve(addr, 4u, &off)) { + p = &ram[off]; + } else if (phys >= 0x1F800000u && phys <= 0x1F8003FCu) { + p = &scratchpad[phys - 0x1F800000u]; + } else if (phys >= 0x1FC00000u && phys <= 0x1FC7FFFCu) { + p = &bios_rom[phys - 0x1FC00000u]; + } else { + return 0; + } + if (out) memcpy(out, p, sizeof(*out)); + return 1; +} void memory_clear_low_boot_scratch(void) { memset(ram, 0, 0x10u); } diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index 3341c7b9..2e98819d 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -57,6 +57,7 @@ uint32_t ls_read_hook(uint32_t a, int s, uint32_t v) { (void)a; (void)s; return void ls_write_hook(uint32_t a, int s, uint32_t v) { (void)a; (void)s; (void)v; } void ls_shadow_record_unreplayable(void) {} int fntrace_is_game_started(void) { return 0; } +void fntrace_restore_game_started(int started) { (void)started; } void overlay_loader_note_code_write(void) {} void overlay_loader_resync_validation_after_restore(void) {} void gte_canonicalize_cpu_state(CPUState *cpu) { (void)cpu; } diff --git a/runtime/tests/test_psx_ram_runtime_map.c b/runtime/tests/test_psx_ram_runtime_map.c index 320a586a..148c2d23 100644 --- a/runtime/tests/test_psx_ram_runtime_map.c +++ b/runtime/tests/test_psx_ram_runtime_map.c @@ -35,6 +35,7 @@ extern void psx_write_word(uint32_t addr, uint32_t val); extern void psx_write_half(uint32_t addr, uint16_t val); extern void psx_write_byte(uint32_t addr, uint8_t val); extern uint8_t *memory_get_ram_ptr(void); +extern uint8_t *memory_get_scratchpad_ptr(void); /* Stub-side mutation counters (psx_ram_runtime_map_stubs.c). */ extern int g_stub_device_restores; @@ -130,6 +131,39 @@ static void check_guest_access(int expanded) { check(psx_read_byte(0x80204005u) == 0x5Au, "byte reads back at its own address"); } +/* ---- psx_peek_word: debug read that must never reach the bus ----------- */ +static void check_peek(int expanded) { + uint32_t v = 0xDEADBEEFu; + memset(memory_get_ram_ptr(), 0, PSX_MAIN_RAM_BACKING_BYTES); + psx_write_word(0x80001000u, 0x11223344u); + check(psx_peek_word(0x80001000u, &v) && v == 0x11223344u, "peek: KSEG0 RAM"); + check(psx_peek_word(0x00001000u, &v) && v == 0x11223344u, "peek: KUSEG RAM"); + check(psx_peek_word(0xA0001000u, &v) && v == 0x11223344u, "peek: KSEG1 RAM"); + v = 0; + check(psx_peek_word(0x80201000u, &v) && ((v == 0x11223344u) == !expanded), + "peek: 2nd mirror follows the live geometry"); + check(psx_peek_word(0x807FFFFCu, &v), "peek: top of the RAM window"); + memory_get_scratchpad_ptr()[0x10] = 0x78; memory_get_scratchpad_ptr()[0x13] = 0x12; + check(psx_peek_word(0x1F800010u, &v) && (v & 0xFF) == 0x78 && (v >> 24) == 0x12, + "peek: scratchpad"); + check(psx_peek_word(0x1F8003FCu, &v) && !psx_peek_word(0x1F800400u, &v), + "peek: scratchpad end bound"); + check(psx_peek_word(0xBFC00000u, &v) && psx_peek_word(0x1FC7FFFCu, &v) && + !psx_peek_word(0x1FC80000u, &v), "peek: BIOS ROM bounds"); + /* Device / unmapped space is refused WITHOUT touching the bus (the stubbed + * psx_fatal_halt traps if a real access reached unmapped_fatal). */ + v = 0xDEADBEEFu; + check(!psx_peek_word(0x1F801044u, &v), "peek: JOY_RX refused"); + check(!psx_peek_word(0x1F801810u, &v), "peek: GPUREAD refused"); + check(!psx_peek_word(0x1F802000u, &v), "peek: expansion-2 MMIO refused"); + check(!psx_peek_word(0x1F000000u, &v), "peek: expansion-1 refused"); + check(!psx_peek_word(0x80800000u, &v), "peek: past the RAM window refused"); + check(!psx_peek_word(0xFFFE0130u, &v) && !psx_peek_word(0xC0000000u, &v), + "peek: KSEG2 refused"); + check(!psx_peek_word(0x80001002u, &v), "peek: unaligned refused"); + check(v == 0xDEADBEEFu, "peek: refused reads leave *out untouched"); +} + /* ---- 3. a real DMA writing RAM from a guest-chosen MADR --------------- */ #define DMA_BASE 0x1F801080u static void run_otc(uint32_t madr, uint32_t entries) { @@ -288,6 +322,7 @@ int main(void) { for (int expanded = 0; expanded < 2; ++expanded) { set_geometry(expanded); check_guest_access(expanded); + check_peek(expanded); check_dma(expanded); } check_savestates(); From 88bf93f380469cd888afc77ed51b3b9e1d8eed3b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:50:02 -0700 Subject: [PATCH 8/8] disasm TCP: side-effect-free reads, reject bad addr, compile only with debug tools Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/runtime.cmake | 9 +++++++-- runtime/src/debug_server.c | 31 +++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 3d11d109..d3a9ebab 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -449,11 +449,16 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/netplay_exit_reason.c ${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp ${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp - ${PSXRECOMP_ROOT}/recompiler/src/mips_decoder.cpp - ${PSXRECOMP_ROOT}/runtime/src/disasm_shim.cpp # (sljit Tier-2 in-process JIT backend removed 2026-07-15 — was disabled by # default since 2026-06-25; gaps fall to the interpreter, gcc/tcc unaffected.) ) +# TCP `disasm` command only (debug_server.c); stripped with the rest of the TCP +# server when PSX_DEBUG_TOOLS is OFF. +if(PSX_DEBUG_TOOLS) + list(APPEND PSXRECOMP_RUNTIME_SOURCES + ${PSXRECOMP_ROOT}/recompiler/src/mips_decoder.cpp + ${PSXRECOMP_ROOT}/runtime/src/disasm_shim.cpp) +endif() # Optional delay-sync netplay (recomp-net). Auto-discovers a sibling checkout # (…/recomp-net next to the game repo or next to psxrecomp). Override with diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 3d649f6d..b485d092 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -27,7 +27,9 @@ #include "overlay_backend.h" #include "cpu_state.h" #include "pgxp.h" +#ifndef PSX_NO_DEBUG_TOOLS #include "psx_disasm.h" +#endif #include "dma.h" #include "gpu.h" #include "gpu_render.h" /* gr_scale + gr_render_display_hires (screenshot_hires) */ @@ -5184,15 +5186,27 @@ static void handle_get_registers(int id, const char *json) } /* disasm: disassemble guest instructions. {"cmd":"disasm","addr":"0x8001121C", - * "count":32}. Reads live guest RAM via psx_read_word, so it works on EXE code - * and on RAM-installed/overlay code alike. Uses the recompiler's MIPS decoder. */ + * "count":32}. Reads live guest memory, so it works on EXE code and on + * RAM-installed/overlay code alike. Uses the recompiler's MIPS decoder. + * Reads go through psx_peek_word (main RAM / scratchpad / BIOS ROM only, no + * device reads, no read hooks); a word outside those comes back as + * {"word":null,"text":""}. Compiled only with the debug tools, + * like the sources it needs (runtime.cmake). */ +#ifndef PSX_NO_DEBUG_TOOLS static void handle_disasm(int id, const char *json) { char addr_str[32]; if (!json_get_str(json, "addr", addr_str, sizeof(addr_str))) { send_err(id, "missing addr"); return; } - uint32_t addr = hex_to_u32(addr_str) & ~3u; + const char *hex = addr_str; + if (hex[0] == '0' && (hex[1] == 'x' || hex[1] == 'X')) hex += 2; + char *hex_end = NULL; + unsigned long long addr_val = strtoull(hex, &hex_end, 16); + if (hex_end == hex || *hex_end != '\0' || addr_val > 0xFFFFFFFFull) { + send_err(id, "bad addr"); return; + } + uint32_t addr = (uint32_t)addr_val & ~3u; int count = json_get_int(json, "count", 16); if (count < 1) count = 1; if (count > 256) count = 256; @@ -5206,7 +5220,13 @@ static void handle_disasm(int id, const char *json) char text[128]; for (int i = 0; i < count; i++) { uint32_t a = addr + (uint32_t)i * 4u; - uint32_t w = psx_read_word(a); + uint32_t w = 0; + if (!psx_peek_word(a, &w)) { + pos += snprintf(out + pos, bufsz - (size_t)pos, + "%s{\"addr\":\"0x%08X\",\"word\":null,\"text\":\"\"}", + i ? "," : "", a); + continue; + } text[0] = '\0'; (void)psx_disasm_one(w, a, text, (int)sizeof(text)); pos += snprintf(out + pos, bufsz - (size_t)pos, @@ -5217,6 +5237,7 @@ static void handle_disasm(int id, const char *json) debug_server_send_line(out); free(out); } +#endif /* !PSX_NO_DEBUG_TOOLS */ static void handle_read_ram(int id, const char *json) { @@ -14451,7 +14472,9 @@ static const CmdEntry s_commands[] = { { "c0_history", handle_c0_history }, { "capture_quads", handle_capture_quads }, { "get_quads", handle_get_quads }, +#ifndef PSX_NO_DEBUG_TOOLS { "disasm", handle_disasm }, +#endif { "gte_state", handle_gte_state }, { "gte_ring_dump", handle_gte_ring_dump }, { "gte_intpl_dump", handle_gte_intpl_dump },