From a3e8f2818b988bec7ec6824828d0b6dcdfba8763 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 27 Jun 2026 00:00:22 -0700 Subject: [PATCH] feat(timing): faithful mult/div completion-stall + ruler #2 native half (validated EXACT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-2's biggest cost gap closed. MULT/MULTU/DIV/DIVU now set a completion deadline; MFLO/MFHI stall guest cycles until it — exactly like Beetle's muldiv_ts_done. Latencies transcribed from cpu.cpp: DIV/DIVU=37; MULT via MULT_Tab24 (14/10/7) on the first-operand magnitude. Native previously charged ZERO for this. - Per-instruction cycle charging is now the DEFAULT on this audit branch (PSX_CODEGEN_CYCLE_PER_INSN), required so the stall absorbs correctly (the running cycle count must be accurate mid-block). Validated cycle-neutral for the identity base model; Tomba 2 still reaches the FMV. - runtime: CPUState.muldiv_ts_done + psx_muldiv_set/stall + psx_mult_latency_s/u in psx_cycles.c. Emitted at the mult/div/mflo/mfhi sites in the GAME emitter (gated PSX_ENABLE_BLOCK_CYCLES). Stall fires even for mflo/mfhi to $zero. - ruler #2 native half: tools/cycle_testrom/CMakeLists.txt builds psx-cyctest (links the recompiled test EXE), boots disc/cyctest.cue (serial CYCT-00101 so disc-identity matches, no warning dialog). measure.py --port 4600. VALIDATION (native vs Beetle oracle, per-iteration component cost): div +38 vs +38 EXACT | div_spaced +38 vs +38 EXACT (absorb works) | mult +15 vs +15 EXACT | baseline/alu match. Remaining: load native +7 vs Beetle +5 (memory.c flat +6 wait-state needs calibration to ~4) — a smaller refinement. BIOS emitter still block-mode (ruler #1 unchanged at 30) — applying per-insn + muldiv there is the next step to close ruler #1's div-stall gap. No FMV regression (i_stat 0x8D, screenshot verified). Co-Authored-By: Claude Opus 4.8 --- recompiler/src/code_generator.cpp | 38 ++++++++++++++++------- runtime/include/cpu_state.h | 16 ++++++++++ runtime/src/psx_cycles.c | 46 ++++++++++++++++++++++++++++ tools/cycle_testrom/.gitignore | 2 ++ tools/cycle_testrom/CMakeLists.txt | 30 ++++++++++++++++++ tools/cycle_testrom/disc/SYSTEM.CNF | 2 +- tools/cycle_testrom/disc/cyctest.xml | 8 ++--- tools/cycle_testrom/game.toml | 8 ++--- 8 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 tools/cycle_testrom/CMakeLists.txt diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 94437cd5..6546d34f 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -15,8 +15,16 @@ namespace PSXRecomp { static bool codegen_cycle_per_insn() { + // DEFAULT ON for the faithful-timing (cycle-audit) branch: each instruction + // charges its cost at its own site, so the running cycle count is correct + // mid-block. This is REQUIRED for stateful timing — mult/div completion-stall + // (mflo/mfhi wait for muldiv_ts_done) and later GTE stalls — to absorb + // correctly, exactly like Beetle's per-instruction timestamp. Block-up-front + // charging loses the in-between timing and can only charge full latency. + // Set PSX_CODEGEN_CYCLE_PER_INSN=0 to force the old block-up-front mode. const char* e = std::getenv("PSX_CODEGEN_CYCLE_PER_INSN"); - return e != nullptr && e[0] != '\0' && e[0] != '0'; + if (e != nullptr && e[0] != '\0') return e[0] != '0'; + return true; } /* @@ -628,16 +636,18 @@ std::string CodeGenerator::translate_mult(uint32_t instr) { uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); - return fmt::format("{{ int64_t result = (int64_t)(int32_t){} * (int64_t)(int32_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}", - reg_name(rs), reg_name(rt)); + return fmt::format("{{ int64_t result = (int64_t)(int32_t){} * (int64_t)(int32_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_s({}));\n#endif", + reg_name(rs), reg_name(rt), reg_name(rs)); } std::string CodeGenerator::translate_multu(uint32_t instr) { uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); - return fmt::format("{{ uint64_t result = (uint64_t){} * (uint64_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}", - reg_name(rs), reg_name(rt)); + return fmt::format("{{ uint64_t result = (uint64_t){} * (uint64_t){}; cpu->lo = (uint32_t)result; cpu->hi = (uint32_t)(result >> 32); }}" + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, psx_mult_latency_u({}));\n#endif", + reg_name(rs), reg_name(rt), reg_name(rs)); } std::string CodeGenerator::translate_div(uint32_t instr) { @@ -646,7 +656,8 @@ std::string CodeGenerator::translate_div(uint32_t instr) { // MIPS division: quotient in LO, remainder in HI // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = (uint32_t)((int32_t){} / (int32_t){}); cpu->hi = (uint32_t)((int32_t){} % (int32_t){}); }} else {{ cpu->lo = ((int32_t){} >= 0) ? 0xFFFFFFFF : 1; cpu->hi = (uint32_t){}; }}", + return fmt::format("if ({} != 0) {{ cpu->lo = (uint32_t)((int32_t){} / (int32_t){}); cpu->hi = (uint32_t)((int32_t){} % (int32_t){}); }} else {{ cpu->lo = ((int32_t){} >= 0) ? 0xFFFFFFFF : 1; cpu->hi = (uint32_t){}; }}" + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rs)); } @@ -656,28 +667,33 @@ std::string CodeGenerator::translate_divu(uint32_t instr) { // MIPS division: quotient in LO, remainder in HI // Handle division by zero (MIPS behavior: result is unpredictable but doesn't trap) - return fmt::format("if ({} != 0) {{ cpu->lo = {} / {}; cpu->hi = {} % {}; }} else {{ cpu->lo = 0xFFFFFFFF; cpu->hi = {}; }}", + return fmt::format("if ({} != 0) {{ cpu->lo = {} / {}; cpu->hi = {} % {}; }} else {{ cpu->lo = 0xFFFFFFFF; cpu->hi = {}; }}" + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_set(cpu, 37u);\n#endif", reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs), reg_name(rt), reg_name(rs)); } std::string CodeGenerator::translate_mfhi(uint32_t instr) { uint32_t rd = get_rd(instr); + // Stall until the mult/div completion deadline (faithful R3000A) — happens + // even when rd==$zero (the read still stalls on HW). + const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; + return fmt::format("/* nop: write to $zero */{}", stall); } - return fmt::format("{} = cpu->hi;", reg_name(rd)); + return fmt::format("{} = cpu->hi;{}", reg_name(rd), stall); } std::string CodeGenerator::translate_mflo(uint32_t instr) { uint32_t rd = get_rd(instr); + const char* stall = "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_muldiv_stall(cpu);\n#endif"; if (config_.optimize_zero_reg && rd == 0) { - return "/* nop: write to $zero */"; + return fmt::format("/* nop: write to $zero */{}", stall); } - return fmt::format("{} = cpu->lo;", reg_name(rd)); + return fmt::format("{} = cpu->lo;{}", reg_name(rd), stall); } std::string CodeGenerator::translate_mthi(uint32_t instr) { diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 1a0ebd8c..60c98fb7 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -55,6 +55,12 @@ typedef struct CPUState { * precompiled gcc overlay DLLs (which bake field offsets) are unaffected; * only the sljit JIT reads it. Populated once at startup. */ void *sljit_helpers[SLJIT_HLP_COUNT]; + + /* Mult/div completion deadline (absolute guest cycle). Set by MULT/MULTU/ + * DIV/DIVU to psx_cycle_count()+latency; a later MFLO/MFHI stalls (advances + * cycles) until this deadline — faithful R3000A behavior (Beetle + * muldiv_ts_done). Appended at END so prior field offsets are unchanged. */ + uint64_t muldiv_ts_done; } CPUState; /* Trap trampolines — defined in runtime/src/traps.c */ @@ -66,6 +72,16 @@ typedef struct CPUState { * value and rely on cpu->pc (0 = handled, resume at caller). */ extern int psx_syscall(CPUState* cpu, uint32_t code); extern void psx_arith_overflow(CPUState* cpu); +/* Mult/div completion-stall timing (psx_cycles.c). MULT/MULTU/DIV/DIVU call + * psx_muldiv_set with their latency (DIV/DIVU = 37; MULT/MULTU via the latency + * helpers, indexed on the first operand magnitude). MFLO/MFHI call + * psx_muldiv_stall, which advances guest cycles to the deadline if not yet + * reached. Faithful only with per-instruction cycle charging. */ +extern void psx_muldiv_set(CPUState* cpu, uint32_t latency); +extern void psx_muldiv_stall(CPUState* cpu); +extern uint32_t psx_mult_latency_s(uint32_t rs); /* MULT (signed) */ +extern uint32_t psx_mult_latency_u(uint32_t rs); /* MULTU (unsigned) */ + extern void psx_unaligned_access(CPUState* cpu, uint32_t addr, uint32_t pc); extern void psx_break(CPUState* cpu, uint32_t code, uint32_t pc); /* Fail-closed native entry guard: a function's CPS entry-switch calls this when diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index fa134800..bfadbcaf 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -1,6 +1,7 @@ /* psx_cycles.c — PSX guest CPU cycle clock. */ #include "psx_cycles.h" +#include "cpu_state.h" #include "cdrom.h" #include "dma.h" #include "interrupts.h" @@ -66,3 +67,48 @@ void psx_advance_cycles(uint32_t cycles) { uint64_t psx_get_cycle_count(void) { return psx_cycle_count; } + +/* ---- Mult/div completion-stall timing (faithful R3000A; Beetle muldiv_ts_done) ---- + * + * MULT/MULTU/DIV/DIVU don't stall at the op; they set a completion DEADLINE. + * A later MFLO/MFHI that reads HI/LO before the deadline STALLS (advances guest + * cycles) until it. Instructions executed in between absorb the latency — so the + * stall is (deadline - now), not a flat charge (this is why div+2filler+mflo costs + * the same as div+mflo: the fillers ran during the latency window). REQUIRES + * per-instruction cycle charging (PSX_CODEGEN_CYCLE_PER_INSN / the interp), so + * `now` is the true cycle position at the op — block-up-front charging breaks it. + * + * Latencies transcribed from Beetle cpu.cpp: DIV/DIVU = 37 (fixed). MULT/MULTU = + * MULT_Tab24 indexed by the leading-zero count of the (sign-folded, for signed) + * first operand | 0x400 — i.e. 14 for small magnitudes (<12 significant bits), + * 10 for medium, 7 for large. The | 0x400 caps the index at 21 (never l==0). */ + +static const uint8_t PSX_MULT_TAB24[24] = { + /* i<12: 7+4+3=14 */ 14,14,14,14,14,14,14,14,14,14,14,14, + /* 12<=i<21: 7+3=10 */ 10,10,10,10,10,10,10,10,10, + /* i>=21: 7 */ 7,7,7 +}; + +static inline uint32_t psx_clz32(uint32_t v) { + /* v is never 0 here (callers OR in 0x400). */ + return (uint32_t)__builtin_clz(v); +} + +uint32_t psx_mult_latency_s(uint32_t rs) { /* MULT (signed): sign-fold magnitude */ + return PSX_MULT_TAB24[psx_clz32((rs ^ (uint32_t)((int32_t)rs >> 31)) | 0x400u)]; +} +uint32_t psx_mult_latency_u(uint32_t rs) { /* MULTU (unsigned) */ + return PSX_MULT_TAB24[psx_clz32(rs | 0x400u)]; +} + +/* DIV/DIVU latency is the fixed constant 37 — emitted directly at the op site. */ + +void psx_muldiv_set(CPUState* cpu, uint32_t latency) { + cpu->muldiv_ts_done = psx_cycle_count + (uint64_t)latency; +} + +void psx_muldiv_stall(CPUState* cpu) { + if (cpu->muldiv_ts_done > psx_cycle_count) { + psx_advance_cycles((uint32_t)(cpu->muldiv_ts_done - psx_cycle_count)); + } +} diff --git a/tools/cycle_testrom/.gitignore b/tools/cycle_testrom/.gitignore index 71ef760c..e8ff3050 100644 --- a/tools/cycle_testrom/.gitignore +++ b/tools/cycle_testrom/.gitignore @@ -11,3 +11,5 @@ generated/ disc/license_data.dat disc/*.bin disc/*.cue +build/ +psx_freeze_heartbeat.json diff --git a/tools/cycle_testrom/CMakeLists.txt b/tools/cycle_testrom/CMakeLists.txt new file mode 100644 index 00000000..69ea0bae --- /dev/null +++ b/tools/cycle_testrom/CMakeLists.txt @@ -0,0 +1,30 @@ +# Native test-runtime for the cycle micro-benchmark ROM (ruler #2). +# Links the recompiled test EXE into the standard runtime so it boots the +# synthetic disc (disc/cyctest.cue) and runs the isolation loops with the real +# cost model. measure.py --port 4600 then reads per-iteration cycle costs to +# compare against the Beetle oracle (4382). +cmake_minimum_required(VERSION 3.20) +project(CycleTestROM C CXX) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_CXX_STANDARD 17) + +# The psxrecomp worktree root is two levels up (tools/cycle_testrom -> worktree). +set(PSXRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH + "Path to psxrecomp") + +include("${PSXRECOMP_ROOT}/runtime/runtime.cmake") + +set(GAME_GENERATED_FULL + "${CMAKE_CURRENT_SOURCE_DIR}/generated/cycle_testrom.exe_full.c") +set(GAME_GENERATED_DISPATCH + "${CMAKE_CURRENT_SOURCE_DIR}/generated/cycle_testrom.exe_dispatch.c") + +psxrecomp_add_runtime_target(psx-cyctest + GAME_GENERATED_FULL_C "${GAME_GENERATED_FULL}" + GAME_GENERATED_DISPATCH_C "${GAME_GENERATED_DISPATCH}" + DEBUG_PORT 4600 + WINDOW_TITLE "Cycle Test ROM" + DEFAULT_BIOS_PATH "${PSXRECOMP_ROOT}/bios/SCPH1001.BIN" + DEFAULT_GAME_CONFIG_PATH "game.toml" +) diff --git a/tools/cycle_testrom/disc/SYSTEM.CNF b/tools/cycle_testrom/disc/SYSTEM.CNF index 2a5fc220..d17c10c3 100644 --- a/tools/cycle_testrom/disc/SYSTEM.CNF +++ b/tools/cycle_testrom/disc/SYSTEM.CNF @@ -1,4 +1,4 @@ -BOOT = cdrom:\CYCTEST.EXE;1 +BOOT = cdrom:\CYCT_001.01;1 TCB = 4 EVENT = 10 STACK = 801FFF00 diff --git a/tools/cycle_testrom/disc/cyctest.xml b/tools/cycle_testrom/disc/cyctest.xml index c4fa189a..9b4b143a 100644 --- a/tools/cycle_testrom/disc/cyctest.xml +++ b/tools/cycle_testrom/disc/cyctest.xml @@ -4,16 +4,16 @@ - - + + diff --git a/tools/cycle_testrom/game.toml b/tools/cycle_testrom/game.toml index 92de7f6f..7dea639b 100644 --- a/tools/cycle_testrom/game.toml +++ b/tools/cycle_testrom/game.toml @@ -4,15 +4,15 @@ # native cost-model side; sideloaded raw into Beetle for the HW-oracle side. [game] name = "CycleTestROM" -id = "CYCTEST-001" -exe = "tools/cycle_testrom/cycle_testrom.exe" +id = "CYCT-00101" +exe = "cycle_testrom.exe" load_address = "0x80010000" entry_pc = "0x80010000" text_size = "0x00000800" [recompiler] -seeds = "tools/cycle_testrom/seeds.txt" -out_dir = "tools/cycle_testrom/generated" +seeds = "seeds.txt" +out_dir = "generated" strict = true [runtime]