feat(timing): faithful mult/div completion-stall + ruler #2 native half (validated EXACT)

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 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 00:00:22 -07:00
parent 47bcfecb21
commit a3e8f2818b
8 changed files with 130 additions and 20 deletions
+27 -11
View File
@@ -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) {
+16
View File
@@ -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
+46
View File
@@ -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));
}
}
+2
View File
@@ -11,3 +11,5 @@ generated/
disc/license_data.dat
disc/*.bin
disc/*.cue
build/
psx_freeze_heartbeat.json
+30
View File
@@ -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"
)
+1 -1
View File
@@ -1,4 +1,4 @@
BOOT = cdrom:\CYCTEST.EXE;1
BOOT = cdrom:\CYCT_001.01;1
TCB = 4
EVENT = 10
STACK = 801FFF00
+4 -4
View File
@@ -4,16 +4,16 @@
<identifiers
system = "PLAYSTATION"
application = "PLAYSTATION"
volume = "CYCTEST"
volume_set = "CYCTEST"
volume = "CYCT00101"
volume_set = "CYCT00101"
publisher = "PSXRECOMP"
data_preparer= "MKPSXISO"
copyright = "SYSTEM.CNF;1"
/>
<license file="license_data.dat"/>
<directory_tree>
<file name="system.cnf" type="data" source="SYSTEM.CNF"/>
<file name="cyctest.exe" type="data" source="../cycle_testrom.exe"/>
<file name="system.cnf" type="data" source="SYSTEM.CNF"/>
<file name="cyct_001.01" type="data" source="../cycle_testrom.exe"/>
</directory_tree>
</track>
</iso_project>
+4 -4
View File
@@ -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]