feat(timing): single-source cycle-cost seam (psx_instr_base_cycles), identity

P3 step 1 (FAITHFUL_TIMING_PLAN.md): both backends now account guest CPU cycles
through ONE function, runtime/include/psx_instr_cost.h::psx_instr_base_cycles(insn),
so they can never disagree (the -8 drift class). Interp routes its 3 per-insn
charges (exec_delay_slot, dirty dispatch loop, precise slice) through it; the
recompiler sums it over each block (+ the outside delay-slot clone) to fold into
the compile-time block charge. Stage 1 = identity (1 cycle/insn) = the proven
baseline, so the seam is BEHAVIOR-PRESERVING: regen is byte-identical (verified,
full.c + dispatch.c) and Tomba 2 still reaches the intro FMV (i_stat 0x8D
streaming). Stage 2 edits ONLY psx_instr_base_cycles (transcribe real R3000A
costs from in-tree Beetle cpu.cpp/gte.cpp + psx-spx, verify each vs Beetle) and
both backends update together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-26 19:20:20 -07:00
parent 607292044f
commit b00b81f5e9
3 changed files with 71 additions and 6 deletions
+19 -2
View File
@@ -5,6 +5,7 @@
// included via relative path to avoid an include-dir collision (recompiler and
// runtime both ship a gte.h).
#include "../../runtime/include/ws_backdrop_detect.h"
#include "../../runtime/include/psx_instr_cost.h" /* single-source CPU cycle cost (shared with interp) */
#include <fmt/format.h>
#include <algorithm>
#include <cstdlib>
@@ -1208,8 +1209,24 @@ std::string CodeGenerator::translate_basic_block(
block.exit_instr.has_delay_slot &&
block.exit_instr.type != ControlFlowType::None &&
block.exit_instr.address == block.end_addr;
const uint32_t block_exec_cycles =
static_cast<uint32_t>(block.instruction_count) + (exit_delay_slot_outside ? 1u : 0u);
// Block cycle charge = sum of the single-source per-instruction cost
// (psx_instr_cost.h) over every instruction the block executes, INCLUDING
// the always-executed delay-slot clone when the exit branch sits at end_addr
// (delay slot outside [start,end]). The interpreter charges the same cost
// per instruction through the same function, so the two backends cannot
// disagree. Stage 1 cost is identity (1/insn) => this equals
// instruction_count(+1 outside delay) exactly => byte-identical regen; Stage 2
// edits ONLY psx_instr_base_cycles and both backends update together.
uint32_t block_exec_cycles = 0;
for (uint32_t a = block.start_addr; a <= block.end_addr; a += 4) {
auto w = exe_.read_word(a);
if (!w.has_value()) break;
block_exec_cycles += psx_instr_base_cycles(*w);
}
if (exit_delay_slot_outside) {
auto dw = exe_.read_word(block.end_addr + 4u); // the delay-slot clone
if (dw.has_value()) block_exec_cycles += psx_instr_base_cycles(*dw);
}
{
uint32_t slice_bcyc = block_exec_cycles;
+46
View File
@@ -0,0 +1,46 @@
/* psx_instr_cost.h — SINGLE SOURCE OF TRUTH for R3000A guest CPU cycle cost.
*
* FAITHFUL_TIMING_PLAN.md P3/Stage-2. Both execution backends — the dirty-RAM
* interpreter (runtime/src/dirty_ram_interp.c) and the static recompiler
* (recompiler/src/code_generator.cpp, which sums this over a block at gen time
* to fold it into one compile-time block charge) — MUST account guest cycles
* THROUGH THIS FUNCTION so they can never disagree (the -8 drift class).
*
* STAGE 1 (now): identity, 1 cycle per instruction. This is the proven baseline
* both backends already used, so routing them through here is BEHAVIOR-PRESERVING
* (a regen must be byte-identical) and merely establishes the seam.
*
* STAGE 2 (next): replace the body with the documented R3000A model, transcribed
* (facts, not code) from the in-tree oracle psxrecomp/beetle-psx/mednafen/psx/
* (cpu.cpp MULT_Tab/muldiv, gte.cpp GTE_Instruction per-command table) and
* psx-spx, each value VERIFIED against Beetle at runtime before it is trusted.
* Memory-access wait-states are charged separately in the psx_read/write path
* (they are address/region-dependent, not knowable from the opcode alone), so
* this function returns ONLY the CPU instruction/execute base cost. Keep that
* split clean in both backends to avoid double-counting.
*
* Header is plain C (static inline) so both the C runtime and the C++ recompiler
* can include it as the one definition.
*/
#ifndef PSX_INSTR_COST_H
#define PSX_INSTR_COST_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* CPU instruction base cycle cost (execute cycles only; excludes data-access
* wait states, which the memory path charges). `insn` is the raw 32-bit MIPS
* word. Stage 1: identity. */
static inline uint32_t psx_instr_base_cycles(uint32_t insn) {
(void)insn;
return 1u;
}
#ifdef __cplusplus
}
#endif
#endif /* PSX_INSTR_COST_H */
+6 -4
View File
@@ -28,6 +28,7 @@
#include "debug_server.h"
#include "interrupts.h"
#include "psx_cycles.h"
#include "psx_instr_cost.h" /* psx_instr_base_cycles — single-source cycle cost */
#include "gpu.h" /* psx_ws_is_backdrop_site / psx_ws_backdrop_x (interp hook) */
#include "ws_backdrop_detect.h" /* shared backdrop-window detector (auto_backdrop) */
@@ -904,9 +905,10 @@ static void exec_delay_slot(CPUState *cpu, uint32_t pc) {
* pair costs 2 — matching the recompiler's block.instruction_count (which
* counts both) and hardware. Without this the dirty-interp under-charges 1
* cycle per taken branch vs compiled-overlay code, drifting the shared
* guest-cycle timeline and forking timer-sensitive code (Tomba2 logo). */
* guest-cycle timeline and forking timer-sensitive code (Tomba2 logo).
* Charged through the single-source cost fn (psx_instr_cost.h). */
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(1u);
psx_advance_cycles(psx_instr_base_cycles(insn));
#endif
}
@@ -1586,7 +1588,7 @@ static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) {
g_unsupported_seen = 0;
int transferred = exec_one(cpu, pc, &next_pc);
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(1u);
psx_advance_cycles(psx_instr_base_cycles(fetch_word(pc & 0x1FFFFFFFu)));
#endif
g_dirty_ram_insns_run++;
if (g_unsupported_seen) {
@@ -1861,7 +1863,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
}
}
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(1u);
psx_advance_cycles(psx_instr_base_cycles(insn)); /* single-source cost */
#endif
dirty_ram_log_instruction(cpu, pc, insn, before_s0, next_pc,
transferred ? cpu->pc : next_pc,