diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 5ab0a00e..0e936574 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -647,6 +647,16 @@ if(BUILD_TESTING) --cmake ${CMAKE_COMMAND} --cc ${CMAKE_C_COMPILER} --cxx ${CMAKE_CXX_COMPILER} --ninja ${CMAKE_MAKE_PROGRAM}) endif() + # MFC0/CFC0 read COP0 from before their own cycle charge: the production + # interpreter decoder with cycle charging on, a device event that raises + # CAUSE.IP2 on the read's own cycle, other seams stubbed to abort() + # (runtime/tests/fixture_link.py needs nm and GNU ld diagnostics). + if(Python3_Interpreter_FOUND AND CMAKE_C_COMPILER_ID STREQUAL "GNU") + add_test(NAME interp_cop0_read_sample + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_interp_cop0_read_sample.py" + --cc ${CMAKE_C_COMPILER}) + endif() if(Python3_Interpreter_FOUND) add_test(NAME optional_netplay_auth COMMAND ${Python3_EXECUTABLE} diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index ef6158b7..3a95f8d0 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1548,6 +1548,16 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, *next_pc_out = pc + 4; + /* MFC0/CFC0 observe COP0 as it stood when the instruction began. The fetch + * and cycle charges below can reach a device deadline and run its event, + * which may raise CAUSE.IP2 in the middle of this instruction. An IRQ + * becomes visible at the next instruction boundary, and compiled code + * already behaves that way because it batches its charges to the + * boundary. Example: the kernel's syscall handler (`mfc0 a1,Cause`) + * saved 0x420 instead of 0x20 when a CD IRQ fell due on its own cycle. */ + const uint32_t cop0_read = (opc == 0x10u && (rs == 0u || rs == 2u)) + ? cpu->cop0[rd] : 0u; + #ifdef PSX_ENABLE_BLOCK_CYCLES /* Instruction FETCH cost (I-cache) — charged FIRST, before the §1 base, exactly * like Beetle ReadInstruction precedes the per-instruction base (cpu.cpp). HIT=+0, @@ -2143,7 +2153,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->ld_absorb = 0u; cpu->ld_which_t = (uint8_t)rt; #endif - cpu->gpr[rt] = cpu->cop0[rd]; + cpu->gpr[rt] = cop0_read; cpu->gpr[0] = 0; return 0; } @@ -2152,7 +2162,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->ld_absorb = 0u; cpu->ld_which_t = (uint8_t)rt; #endif - cpu->gpr[rt] = cpu->cop0[rd]; + cpu->gpr[rt] = cop0_read; cpu->gpr[0] = 0; return 0; } diff --git a/runtime/tests/fixture_link.py b/runtime/tests/fixture_link.py new file mode 100644 index 00000000..bbc3549f --- /dev/null +++ b/runtime/tests/fixture_link.py @@ -0,0 +1,124 @@ +"""Link production runtime modules into a focused C fixture. + +build_and_run() compiles the named runtime/src modules and the fixture, then +links them. Every symbol the modules need but neither they, the fixture nor +the C library define becomes a stub that calls abort(), so a fixture only +defines the seams its path really uses and an unexpected call fails loudly. +GNU toolchains only (nm, GNU ld diagnostics). +""" +import os +import re +import subprocess +from pathlib import Path + +SYMBOL = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +MULTIPLE = re.compile(r"multiple definition of [`']([^`']+)[`']") +UNDEFINED = re.compile(r"undefined reference to [`']([^`']+)[`']") + +# A stub would silently override a symbol the C library would otherwise +# provide from an archive or shared object, so anything libc, the CRT or the +# compiler runtime defines is never stubbed. +LIBRARY_CANDIDATES = ["libc.a", "libm.a", "libgcc.a", "libmingw32.a", + "libmingwex.a", "libmsvcrt.a", "libucrt.a", + "libkernel32.a"] +ISO_C_FALLBACK = { + "abort", "atexit", "atoi", "atol", "calloc", "exit", "fclose", "ferror", + "fflush", "fopen", "fprintf", "fputc", "fputs", "fread", "free", "fwrite", + "getenv", "longjmp", "malloc", "memcpy", "memmove", "memset", "printf", + "putchar", "puts", "qsort", "realloc", "setjmp", "snprintf", "sprintf", + "sscanf", "strcat", "strchr", "strcmp", "strcpy", "strlen", "strncat", + "strncmp", "strncpy", "strstr", "strtol", "strtoll", "strtoul", + "strtoull", "time", "vfprintf", "vsnprintf", +} + + +def _run(args, **kwargs): + return subprocess.run(args, capture_output=True, text=True, + encoding="utf-8", errors="replace", **kwargs) + + +def library_symbols(cc): + provided = set(ISO_C_FALLBACK) + for name in LIBRARY_CANDIDATES: + out = _run([cc, "-print-file-name=" + name]).stdout.strip() + if not out or not os.path.exists(out): + continue + for line in _run(["nm", out]).stdout.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[-2] != "U" and SYMBOL.match(parts[-1]): + provided.add(parts[-1]) + return provided + + +def nm_symbols(objs): + defined, undefined = set(), set() + for obj in objs: + for line in _run(["nm", str(obj)]).stdout.splitlines(): + parts = line.split() + if len(parts) < 2: + continue + sym, typ = parts[-1], parts[-2] + if not SYMBOL.match(sym): + continue + (undefined if typ == "U" else defined).add(sym) + return defined, undefined + + +def write_stubs(path, symbols): + lines = ["#include ", "/* Unrelated link seams must not execute. */"] + lines += ["void %s(void) { abort(); }" % s for s in sorted(symbols)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_and_run(cc, here, src_root, opt, work, modules, test_name, defines=()): + """Compile runtime/src `modules` and `test_name` at `opt`, link, run.""" + tag = opt.replace("-", "") + include = str(src_root / "include") + objs = [] + for src in modules: + obj = work / (Path(src).stem + tag + ".o") + subprocess.run([cc, "-std=c11", opt, "-w", "-I", include, + *["-D" + d for d in defines], + "-c", str(src_root / "src" / src), "-o", str(obj)], + check=True) + objs.append(obj) + + defined, undefined = nm_symbols(objs) + provided = library_symbols(cc) + stubs = {s for s in undefined - defined - provided if not s.startswith("__")} + stub_c = work / ("stubs" + tag + ".c") + stub_o = work / ("stubs" + tag + ".o") + + test_o = work / ("fixture" + tag + ".o") + subprocess.run([cc, "-std=c11", opt, "-Wall", "-Wextra", "-Werror", + "-I", include, "-c", str(here / test_name), "-o", str(test_o)], + check=True) + exe = work / ("fixture" + tag + (".exe" if os.name == "nt" else "")) + + for _ in range(40): + write_stubs(stub_c, stubs) + subprocess.run([cc, "-O0", "-w", "-c", str(stub_c), "-o", str(stub_o)], + check=True) + result = _run([cc, str(test_o)] + [str(o) for o in objs] + + [str(stub_o), "-o", str(exe)]) + if result.returncode == 0: + break + changed = False + for sym in MULTIPLE.findall(result.stderr): + if sym in stubs: + stubs.discard(sym) + changed = True + for sym in UNDEFINED.findall(result.stderr): + if sym not in defined and sym not in provided and sym not in stubs: + stubs.add(sym) + changed = True + if not changed: + raise SystemExit("link did not converge for %s:\n%s" % (opt, result.stderr)) + else: + raise SystemExit("link did not converge for %s" % opt) + + env = {k: v for k, v in os.environ.items() if not k.upper().startswith("PSX_")} + run = _run([str(exe)], env=env, timeout=60) + if run.returncode != 0: + raise SystemExit("%s: fixture failed (rc=%d)\n%s%s" + % (opt, run.returncode, run.stdout, run.stderr)) diff --git a/runtime/tests/interp_cop0_decoder.c b/runtime/tests/interp_cop0_decoder.c new file mode 100644 index 00000000..12141f63 --- /dev/null +++ b/runtime/tests/interp_cop0_decoder.c @@ -0,0 +1,9 @@ +/* Production dirty-RAM interpreter decoder, built with per-instruction cycle + * charging on, for test_interp_cop0_read_sample.c. */ +#define PSX_ENABLE_BLOCK_CYCLES 1 +#define PSX_NO_DEBUG_TOOLS 1 +#include "../src/dirty_ram_interp.c" + +int interp_test_step(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t *next) { + return exec_one_fetched(cpu, pc, insn, next); +} diff --git a/runtime/tests/test_interp_cop0_read_sample.c b/runtime/tests/test_interp_cop0_read_sample.c new file mode 100644 index 00000000..3e160db6 --- /dev/null +++ b/runtime/tests/test_interp_cop0_read_sample.c @@ -0,0 +1,112 @@ +/* MFC0/CFC0 in the dirty-RAM interpreter read COP0 as it stood when the + * instruction began. + * + * The interpreter charges an instruction's fetch and base cycle before its + * body runs. When that charge reaches a device deadline, the device event runs + * first and may raise CAUSE.IP2 (the interrupt line). The IRQ belongs to the + * next instruction boundary, which is where compiled code, batching its + * charges, makes it visible. This runs the production decoder + * (interp_cop0_decoder.c) with cycle charging on and a device service that + * raises IP2 on the read's own cycle: + * + * - the read with the event due on its own cycle sees CAUSE without IP2, + * and CAUSE holds IP2 once the instruction has finished; + * - controls: no event, and an IRQ already raised before the instruction. + * + * test_interp_cop0_read_sample.py links it; every seam not defined here is an + * abort() stub. */ +#include +#include +#include +#include "cpu_state.h" + +int interp_test_step(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t *next); + +/* Clock, cache and device seams read on the charged path. */ +uint64_t psx_cycle_count; +uint64_t psx_next_service_cycle; +uint64_t g_psx_cycle_fast_limit; +int psx_in_device_service; +int g_event_step_conservative; +uint32_t g_psx_cyc_batch; +uint32_t g_psx_cyc_batch_limit; +int g_psx_cyc_bb_defer; +uint32_t *g_psx_cyc_local_acc; +int g_psx_icache_active; /* 0: no fetch cost */ +uint32_t g_psx_icache_tv[1024]; + +static CPUState *s_device_cpu; +static unsigned s_services; +void psx_devices_service_to_now(void) { + ++s_services; + s_device_cpu->cop0[13] |= 0x400u; /* the device raises CAUSE.IP2 */ + psx_next_service_cycle = UINT64_MAX; +} + +static int failures; +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); \ + fputc('\n', stderr); \ + ++failures; \ + } \ + } while (0) + +enum { A1 = 5, CAUSE = 13 }; +#define CAUSE_SYSCALL 0x20u /* ExcCode 8 */ +#define CAUSE_IP2 0x400u + +/* Runs `insn` (MFC0/CFC0 a1,$13) once and returns what it read. The device + * deadline falls on the instruction's own cycle, or never. */ +static uint32_t read_cause(uint32_t insn, int due_on_own_cycle, int raised_before) { + static CPUState cpu; + memset(&cpu, 0, sizeof cpu); + cpu.pc = 0x80020000u; /* outside the BIOS load-delay window */ + cpu.read_fudge = 32; + cpu.ld_which_t = 32; + cpu.cop0[CAUSE] = CAUSE_SYSCALL | (raised_before ? CAUSE_IP2 : 0u); + s_device_cpu = &cpu; + s_services = 0; + psx_cycle_count = 100000u; + psx_next_service_cycle = due_on_own_cycle ? psx_cycle_count + 1u : UINT64_MAX; + + uint32_t next = 0; + CHECK(interp_test_step(&cpu, cpu.pc, insn, &next) == 0, + "0x%08X transferred control", (unsigned)insn); + CHECK(s_services == (due_on_own_cycle ? 1u : 0u), + "0x%08X: %u device services, expected %d", (unsigned)insn, s_services, + due_on_own_cycle); + const uint32_t cause_after = CAUSE_SYSCALL | + ((due_on_own_cycle || raised_before) ? CAUSE_IP2 : 0u); + CHECK(cpu.cop0[CAUSE] == cause_after, + "0x%08X: CAUSE after the instruction is 0x%08X", (unsigned)insn, + (unsigned)cpu.cop0[CAUSE]); + return cpu.gpr[A1]; +} + +int main(void) { + const uint32_t mfc0 = 0x40056800u; /* mfc0 a1,$13 */ + const uint32_t cfc0 = 0x40456800u; /* cfc0 a1,$13 */ + for (int i = 0; i < 2; i++) { + const uint32_t insn = i ? cfc0 : mfc0; + uint32_t got = read_cause(insn, 1, 0); + CHECK(got == CAUSE_SYSCALL, + "0x%08X with the IRQ due on its own cycle read 0x%08X", + (unsigned)insn, (unsigned)got); + got = read_cause(insn, 0, 0); + CHECK(got == CAUSE_SYSCALL, "0x%08X with no event read 0x%08X", + (unsigned)insn, (unsigned)got); + got = read_cause(insn, 0, 1); + CHECK(got == (CAUSE_SYSCALL | CAUSE_IP2), + "0x%08X with the IRQ raised before it read 0x%08X", + (unsigned)insn, (unsigned)got); + } + if (failures) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + puts("PASS MFC0/CFC0 read CAUSE from before their own cycle charge"); + return 0; +} diff --git a/runtime/tests/test_interp_cop0_read_sample.py b/runtime/tests/test_interp_cop0_read_sample.py new file mode 100644 index 00000000..25e5ebe0 --- /dev/null +++ b/runtime/tests/test_interp_cop0_read_sample.py @@ -0,0 +1,23 @@ +"""MFC0/CFC0 read COP0 from before their own cycle charge. + +Links the production decoder (interp_cop0_decoder.c, cycle charging on) into +test_interp_cop0_read_sample.c at -O0 and -O2; every other runtime seam is an +abort() stub (fixture_link.py). +""" +import argparse +import tempfile +from pathlib import Path + +from fixture_link import build_and_run + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--cc', default='gcc') + args = parser.parse_args() + here = Path(__file__).resolve().parent + with tempfile.TemporaryDirectory() as root: + for opt in ('-O0', '-O2'): + build_and_run(args.cc, here, here.parent, opt, Path(root), + [str(here / 'interp_cop0_decoder.c')], + 'test_interp_cop0_read_sample.c') + print('PASS: MFC0/CFC0 read CAUSE from before their own cycle charge (O0/O2)')