diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 4b46ebce..4543ae70 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -431,9 +431,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**334 commands registered** — 321 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**335 commands registered** — 322 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -63 of 334 have prose above; **271 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. +63 of 335 have prose above; **272 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. @@ -506,6 +506,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` | ✓ | | | @@ -775,3 +776,6 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `xprobe_watch` | ✓ | | | + +`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. 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/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 f057641c..b86b7594 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 @@ -121,6 +125,10 @@ 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") + 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}" 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/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/runtime.cmake b/runtime/runtime.cmake index 715831fa..f0236c19 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -457,6 +457,13 @@ set(PSXRECOMP_RUNTIME_SOURCES # (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 c4ab3a40..1baf41a3 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -27,6 +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) */ @@ -5185,6 +5188,60 @@ 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 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; + } + 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; + + 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 = 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, + "%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); +} +#endif /* !PSX_NO_DEBUG_TOOLS */ + static void handle_read_ram(int id, const char *json) { char addr_str[32]; @@ -10073,7 +10130,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; } @@ -11680,6 +11737,22 @@ 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, 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; + 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, * high-traffic ring (which oldest-N / newest-N paging cannot). -1 = unbounded. */ @@ -11703,8 +11776,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) { @@ -11715,6 +11789,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 (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, @@ -14789,6 +14867,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 }, diff --git a/runtime/src/disasm_shim.cpp b/runtime/src/disasm_shim.cpp new file mode 100644 index 00000000..159535d4 --- /dev/null +++ b/runtime/src/disasm_shim.cpp @@ -0,0 +1,136 @@ +#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 (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) { + 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/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/test_disasm.cpp b/runtime/tests/test_disasm.cpp new file mode 100644 index 00000000..2ba577d0 --- /dev/null +++ b/runtime/tests/test_disasm.cpp @@ -0,0 +1,47 @@ +#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(0,0,"NOP"); + 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'); +} 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(); diff --git a/runtime/tests/test_wtrace_dump.py b/runtime/tests/test_wtrace_dump.py new file mode 100644 index 00000000..50cb49f2 --- /dev/null +++ b/runtime/tests/test_wtrace_dump.py @@ -0,0 +1,186 @@ +"""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. +Exits 77 (ctest SKIP_RETURN_CODE) when no C compiler is available. +""" +import json +import os +from pathlib import Path +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") + + def function(name): + match = re.search(r"static [^;{}]+\b" + name + r"\([^;{}]*\)\s*\{", source) + if not match: + raise AssertionError(f"production function missing: {name}") + return source[match.start():matching_brace(source, match.end() - 1) + 1] + + 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; +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", "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,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:(seq==7?3:-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") + 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) + 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 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_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), [9]) + 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__": + if find_compiler() is None: + print("SKIP: no C compiler (set CC)") + sys.exit(SKIP_EXIT) + unittest.main()