From bdbba2a52f8b86705fa3c7e3b89bdecd9e5936f3 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Wed, 30 Sep 2026 22:11:39 +0300 Subject: [PATCH] interp: read cop0 before the instruction's own cycle charge With cycle charging on, the dirty-RAM 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. If it raises an interrupt, CAUSE.IP2 is set before MFC0/CFC0 read CAUSE, so the read sees an IRQ that belongs to the next instruction boundary. Compiled code batches its charges to the boundary and never shows this. Seen in the kernel's syscall handler: `mfc0 a1,Cause` ran interpreted, a CD IRQ fell due on its own cycle, and the handler saved CAUSE 0x420 instead of 0x20. The IRQ itself was taken at the right boundary; only the value saved inside the instruction was wrong. MFC0 and CFC0 now return COP0 as it stood when the instruction began. interp_cop0_read_sample links the production decoder (cycle charging on) into a fixture at -O0 and -O2, with a device service that raises IP2 on the read's own cycle. It reads 0x420 before this change and 0x20 after. Controls cover no event and an IRQ raised before the instruction. fixture_link.py stubs every other runtime seam with abort(), so an unexpected call fails the test. --- recompiler/CMakeLists.txt | 10 ++ runtime/src/dirty_ram_interp.c | 14 +- runtime/tests/fixture_link.py | 124 ++++++++++++++++++ runtime/tests/interp_cop0_decoder.c | 9 ++ runtime/tests/test_interp_cop0_read_sample.c | 112 ++++++++++++++++ runtime/tests/test_interp_cop0_read_sample.py | 23 ++++ 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/fixture_link.py create mode 100644 runtime/tests/interp_cop0_decoder.c create mode 100644 runtime/tests/test_interp_cop0_read_sample.c create mode 100644 runtime/tests/test_interp_cop0_read_sample.py 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)')