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]