timing: faithful R3000A load-delay interlock (ReadFudge/LDAbsorb)

Implements the last unmodeled load-path cycle component — the R3000A
load-delay pipeline interlock (Beetle ReadAbsorb/ReadFudge/LDAbsorb/
LDWhich) — as ONE shared per-instruction model across the dirty interp
and BOTH static emitters, replacing the flat per-instruction +1 base and
the flat +4 main-RAM read wait-state.

Model (transcribed from beetle-psx cpu.cpp + libretro.cpp, verified Δ):
- §1 base: a pending load's per-register give-back (read_absorb) is
  consumed INSTEAD of charging +1 (pipeline write-back overlap).
- GPR_DEPRES: each opcode's src/dst regs end the give-back on use.
- DO_LDS: commits the prior load's give-back + ReadFudge.
- load ReadMemory: +2 fudge iff the predecessor committed no load;
  main-RAM region +3 + completion +2 (LWC2 +1) become the LDAbsorb
  give-back; scratchpad +0.

New runtime/include/psx_cyc.h (inline §1/deps/DO_LDS + step) over new
CPUState timing fields; psx_cyc_load_*/psx_cyc_lwc2_read in memory.c
(region-aware, own the whole load interlock, value via uncharged
psx_read_*); psx_cyc_dep_res_mask in psx_instr_cost.h (per-opcode
GPR_DEP/RES, gen-time literal). cpu->read_* rewired to uncharged in
main.cpp; charge_main_ram_read removed. Wired before each instruction
body in code_generator.cpp (game) and full_function_emitter.cpp +
strict_translator.cpp (BIOS); loads route through psx_cyc_load_*.

Validated against the Beetle oracle:
- ruler #2 load2: native +10 -> +11 == Beetle (all other components
  still exact: alu+1/load+5/div+38/mult+15/gte_rtps+15/gte_nclip+8).
- ruler #1 [0x80001C5C->0x80001CA4]: 54 -> 56 == Beetle steady-state
  (residual 84/77 spikes = I-cache cold refill, the separate P2 axis).
- Tomba 2 boots to the intro FMV (pixels on screen, no regression).

Follow-up (separate commit): GTE-read (MFC2/CFC2) + MFC0 LDAbsorb
give-back, and muldiv-stall give-back consumption — needed for mixed
game/FMV faithfulness but do not affect the rulers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 14:14:47 -07:00
parent da3ab3bdd1
commit d8c4a8ec9f
11 changed files with 502 additions and 220 deletions
+19
View File
@@ -213,6 +213,25 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-27 (load ReadFudge/LDAbsorb — IMPLEMENTED + VALIDATED, both rulers exact):**
Shipped the shared per-instruction R3000A load-delay interlock. New `runtime/include/
psx_cyc.h`: §1 base + GPR_DEPRES + DO_LDS (`psx_cyc_step`) as static-inline helpers over
new CPUState fields `read_absorb[33]/read_absorb_which/read_fudge/ld_which_t/ld_absorb`;
`psx_cyc_load_word/half/byte` + `psx_cyc_lwc2_read` in memory.c do the Beetle ReadMemory
timing (clear give-back, +2 fudge iff predecessor committed no load, region RAM +3 +
completion +2/+1 as the LDAbsorb give-back, scratchpad +0). The pure dep/res classifier
`psx_cyc_dep_res_mask` (transcribed from Beetle per-opcode GPR_DEP/RES) lives in
psx_instr_cost.h. Wired into the dirty interp + BOTH static emitters (code_generator game,
full_function_emitter+strict_translator BIOS); loads now route value reads through the
UNCHARGED psx_read_* (cpu->read_* rewired in main.cpp; the flat +4 charge_main_ram_read is
gone). **Δ-validated against Beetle:** ruler #2 `load2` +10 → **+11** == Beetle, every other
component still exact (alu+1/load+5/div+38/mult+15/gte_rtps+15/gte_nclip+8); ruler #1
[c5c→ca4] 54 → **56** == Beetle steady-state (84/77 spikes = I-cache cold refill, P2). Tomba2
boots to the intro FMV (screenshot pixels, no regression). Builds clean (tools/BIOS/game/
runtime/cyctest). FOLLOW-UP (separate commit): GTE-read/MFC0 give-back + muldiv-stall
give-back consumption (don't affect rulers; needed for mixed-code faithfulness). Supersedes
the "MODEL NAILED, impl pending" entry below.
- **2026-06-27 (load ReadFudge/LDAbsorb — MODEL NAILED empirically, impl pending):**
Derived the last load-path component (the residual on both rulers) by measuring
Beetle's PER-INSTRUCTION cost via adjacent-PC region cyc_watch. Confirmed:
+23 -1
View File
@@ -1,6 +1,28 @@
# Load ReadFudge / LDAbsorb — empirically-derived model + implementation spec
Status: **MODEL NAILED (empirically, 2026-06-27), implementation PENDING.** This is
Status: **IMPLEMENTED + VALIDATED (2026-06-27).** Shipped as the shared per-instruction
R3000A load-delay interlock in `runtime/include/psx_cyc.h` (§1 base + GPR_DEPRES + DO_LDS,
+ `psx_cyc_load_*`/`psx_cyc_lwc2_read` in `memory.c`), driven by `psx_cyc_dep_res_mask`
(`runtime/include/psx_instr_cost.h`, transcribed from Beetle's per-opcode GPR_DEP/RES).
Wired into the dirty interp (`dirty_ram_interp.c`) and BOTH static emitters
(`code_generator.cpp` game path; `full_function_emitter.cpp`+`strict_translator.cpp` BIOS
path). Loads route value reads through the UNCHARGED `psx_read_*` (cpu->read_* rewired in
main.cpp); the data-access cost is charged once, inside the interlock. VALIDATED against the
Beetle oracle: ruler #2 `load2` native +10 → **+11** == Beetle (all other components
unchanged + matching); ruler #1 [0x80001C5C→0x80001CA4] 54 → **56** == Beetle steady-state
(residual 84/77 spikes = I-cache cold refill, the separate P2 axis); Tomba 2 boots to the
intro FMV (no regression). The `(ReadFudge>>4)&2` fudge, the region(RAM +3)+completion(+2)
LDAbsorb give-back, and scratchpad +0 are all modeled per below.
FOLLOW-UP (not in this commit; ACCURACY axis-2): GTE-read (MFC2/CFC2) + MFC0 LDAbsorb
give-back arming, and the muldiv-stall give-back consumption / off-by-one (Beetle
cpu.cpp:1332-1338 / 1723-1736). These don't affect the rulers (no CPU-load give-back in the
div/mult/gte loops) but are needed for full faithfulness in mixed game/FMV code.
---
ORIGINAL SPEC (model derivation) follows.
This is
the last unmodeled piece of the load path — the residual on BOTH rulers
(ruler #1 native 54 vs Beetle 56; ruler #2 `load2` native +10 vs Beetle +11). It
is the R3000A load-delay pipeline interlock (Beetle `ReadFudge`/`ReadAbsorb[]`/
+84 -83
View File
@@ -160,17 +160,16 @@ std::string CodeGenerator::translate_lw(uint32_t instr) {
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
int16_t offset = get_imm16(instr);
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
uint32_t mask = 1u << rs; /* GPR_DEP rs (load: dest rt armed via LDWhich) */
if (config_.optimize_zero_reg && rt == 0) {
return "/* nop: load to $zero */";
}
if (offset == 0) {
return fmt::format("{} = cpu->read_word({});", reg_name(rt), reg_name(rs));
} else {
return fmt::format("{} = cpu->read_word({} + {});",
reg_name(rt), reg_name(rs), offset);
/* load to $zero: no GPR write, but the data access + R3000A interlock still run */
return fmt::format("(void)psx_cyc_load_word(cpu, {}, 0, 0x{:X}u);", addr, mask);
}
return fmt::format("{} = psx_cyc_load_word(cpu, {}, {}, 0x{:X}u);",
reg_name(rt), addr, rt, mask);
}
std::string CodeGenerator::translate_sw(uint32_t instr) {
@@ -323,68 +322,60 @@ std::string CodeGenerator::translate_lb(uint32_t instr) {
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
int16_t offset = get_imm16(instr);
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return "/* nop: load to $zero */";
}
if (offset == 0) {
return fmt::format("{} = (int32_t)(int8_t)cpu->read_byte({});", reg_name(rt), reg_name(rs));
} else {
return fmt::format("{} = (int32_t)(int8_t)cpu->read_byte({} + {});",
reg_name(rt), reg_name(rs), offset);
return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask);
}
return fmt::format("{} = (int32_t)(int8_t)psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);",
reg_name(rt), addr, rt, mask);
}
std::string CodeGenerator::translate_lbu(uint32_t instr) {
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
int16_t offset = get_imm16(instr);
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return "/* nop: load to $zero */";
}
if (offset == 0) {
return fmt::format("{} = cpu->read_byte({});", reg_name(rt), reg_name(rs));
} else {
return fmt::format("{} = cpu->read_byte({} + {});",
reg_name(rt), reg_name(rs), offset);
return fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr, mask);
}
return fmt::format("{} = psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);",
reg_name(rt), addr, rt, mask);
}
std::string CodeGenerator::translate_lh(uint32_t instr) {
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
int16_t offset = get_imm16(instr);
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return "/* nop: load to $zero */";
}
if (offset == 0) {
return fmt::format("{} = (int32_t)(int16_t)cpu->read_half({});", reg_name(rt), reg_name(rs));
} else {
return fmt::format("{} = (int32_t)(int16_t)cpu->read_half({} + {});",
reg_name(rt), reg_name(rs), offset);
return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask);
}
return fmt::format("{} = (int32_t)(int16_t)psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);",
reg_name(rt), addr, rt, mask);
}
std::string CodeGenerator::translate_lhu(uint32_t instr) {
uint32_t rs = get_rs(instr);
uint32_t rt = get_rt(instr);
int16_t offset = get_imm16(instr);
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return "/* nop: load to $zero */";
}
if (offset == 0) {
return fmt::format("{} = cpu->read_half({});", reg_name(rt), reg_name(rs));
} else {
return fmt::format("{} = cpu->read_half({} + {});",
reg_name(rt), reg_name(rs), offset);
return fmt::format("(void)psx_cyc_load_half(cpu, {}, 0, 0x{:X}u);", addr, mask);
}
return fmt::format("{} = psx_cyc_load_half(cpu, {}, {}, 0x{:X}u);",
reg_name(rt), addr, rt, mask);
}
std::string CodeGenerator::translate_lwl(uint32_t instr) {
@@ -396,16 +387,17 @@ std::string CodeGenerator::translate_lwl(uint32_t instr) {
return "/* nop: load to $zero */";
}
// LWL: Load Word Left - loads bytes into the most-significant portion of rt
// addr = rs + offset; byte_pos = addr & 3
// Result merges high bytes from aligned word with low bytes from current rt value
// Generate a helper call: cpu->rt = psx_lwl(cpu, addr, cpu->rt)
if (offset == 0) {
return fmt::format("{} = psx_lwl(cpu, {}, {});", reg_name(rt), reg_name(rs), reg_name(rt));
} else {
return fmt::format("{} = psx_lwl(cpu, {} + {}, {});",
reg_name(rt), reg_name(rs), (int32_t)offset, reg_name(rt));
// LWL: Load Word Left - merges high bytes from the aligned word into rt.
// psx_lwl runs the full R3000A load interlock on the aligned address (GPR_DEP rs,
// arm LDWhich=rt) and returns the merged value.
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), (int32_t)offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return fmt::format("(void)psx_lwl(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask);
}
return fmt::format("{} = psx_lwl(cpu, {}, {}, {}, 0x{:X}u);",
reg_name(rt), addr, reg_name(rt), rt, mask);
}
std::string CodeGenerator::translate_lwr(uint32_t instr) {
@@ -417,16 +409,15 @@ std::string CodeGenerator::translate_lwr(uint32_t instr) {
return "/* nop: load to $zero */";
}
// LWR: Load Word Right - loads bytes into the least-significant portion of rt
// addr = rs + offset; byte_pos = addr & 3
// Result merges low bytes from aligned word with high bytes from current rt value
// Generate a helper call: cpu->rt = psx_lwr(cpu, addr, cpu->rt)
if (offset == 0) {
return fmt::format("{} = psx_lwr(cpu, {}, {});", reg_name(rt), reg_name(rs), reg_name(rt));
} else {
return fmt::format("{} = psx_lwr(cpu, {} + {}, {});",
reg_name(rt), reg_name(rs), (int32_t)offset, reg_name(rt));
// LWR: Load Word Right - merges low bytes from the aligned word into rt.
std::string addr = (offset == 0) ? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), (int32_t)offset);
uint32_t mask = 1u << rs;
if (config_.optimize_zero_reg && rt == 0) {
return fmt::format("(void)psx_lwr(cpu, {}, {}, 0, 0x{:X}u);", addr, reg_name(rt), mask);
}
return fmt::format("{} = psx_lwr(cpu, {}, {}, {}, 0x{:X}u);",
reg_name(rt), addr, reg_name(rt), rt, mask);
}
std::string CodeGenerator::translate_swl(uint32_t instr) {
@@ -1153,19 +1144,20 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
// Faithful GTE: COP2 reg write stalls to the command deadline.
const char* gte_stall =
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n ";
// LWC2 load timing: §1+DO_LDS via the block's psx_cyc_step(cpu,0)
// (op 0x32 is non-load), the GTE deadline stall via psx_gte_stall,
// then psx_cyc_lwc2_read does the ReadMemory timing (completion +1,
// no LDWhich arm — the dest is a GTE register).
std::string addr = (offset == 0)
? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
bool special = (rt == 7 || (rt >= 8 && rt <= 11) || rt == 14 || rt == 15 || rt == 28 || rt == 30);
if (special) {
std::string addr = (offset == 0)
? reg_name(rs)
: fmt::format("{} + {}", reg_name(rs), offset);
code = gte_stall + fmt::format("gte_write_data(cpu, {}, cpu->read_word({})); /* lwc2 gte[{}] */",
code = gte_stall + fmt::format("gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {})); /* lwc2 gte[{}] */",
rt, addr, rt);
} else if (offset == 0) {
code = gte_stall + fmt::format("cpu->gte_data[{}] = cpu->read_word({}); /* lwc2 gte[{}], ({}) */",
rt, reg_name(rs), rt, reg_name(rs));
} else {
code = gte_stall + fmt::format("cpu->gte_data[{}] = cpu->read_word({} + {}); /* lwc2 gte[{}], {}({}) */",
rt, reg_name(rs), offset, rt, offset, reg_name(rs));
code = gte_stall + fmt::format("cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {}); /* lwc2 gte[{}], ({}) */",
rt, addr, rt, addr);
}
}
break;
@@ -1289,9 +1281,18 @@ std::string CodeGenerator::translate_basic_block(
}
const bool cycle_per_insn = codegen_cycle_per_insn();
auto emit_one_cycle = [&](const std::string& indent) {
// Per-instruction R3000A load-delay interlock (cycle_per_insn mode): §1 base +
// GPR_DEPRES + DO_LDS, emitted BEFORE the instruction body so §1 precedes any
// muldiv/GTE deadline stall the body applies (Beetle order). CPU loads (op
// 0x20-0x26) are SKIPPED here — psx_cyc_load_* runs their full interlock inside
// the body (and arms LDWhich=rt). The dep/res mask is a gen-time literal. This
// replaces the old flat per-instruction psx_advance_cycles(1u).
auto emit_pre_timing = [&](uint32_t in, const std::string& indent) {
uint32_t op = in >> 26;
if (op >= 0x20u && op <= 0x26u) return; // CPU load: interlock inside psx_cyc_load_*
ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n";
ss << indent << "psx_advance_cycles(1u);\n";
ss << indent << fmt::format("psx_cyc_step(cpu, 0x{:X}u);\n",
psx_cyc_dep_res_mask(in));
ss << "#endif\n";
};
if (!cycle_per_insn && block_exec_cycles > 0) {
@@ -1352,13 +1353,18 @@ std::string CodeGenerator::translate_basic_block(
}
if (!is_cf) {
if (cycle_per_insn) emit_pre_timing(instr, config_.indent);
ss << translate_instruction(addr, instr) << "\n";
if (cycle_per_insn) {
emit_one_cycle(config_.indent);
}
} else {
// Control flow is handled at block exit
if (addr == exit_branch_addr) {
// Per-instruction interlock ORDER (Beetle): the branch's §1+deps+DO_LDS
// runs at the branch PC, THEN the delay slot's at PC+4. Emit the branch
// step FIRST (it is pure timing — does not touch GPR values, so it is
// safe before the branch-condition capture below).
if (cycle_per_insn)
emit_pre_timing(block.exit_instr.instruction, config_.indent);
// MIPS delay slot handling: emit delay slot instruction BEFORE branch/jump
std::string delay_saved_cond; // non-empty if condition was pre-captured
if (block.exit_instr.has_delay_slot) {
@@ -1387,18 +1393,13 @@ std::string CodeGenerator::translate_basic_block(
delay_saved_cond, cond);
}
ss << config_.indent << "/* delay slot (always executes) */\n";
// Delay slot's own §1+deps+DO_LDS, before its body (skipped if a load).
if (cycle_per_insn) emit_pre_timing(delay_instr, config_.indent);
ss << translate_instruction(delay_slot_addr, delay_instr) << "\n";
if (cycle_per_insn) {
emit_one_cycle(config_.indent);
}
}
}
}
if (cycle_per_insn) {
emit_one_cycle(config_.indent);
}
// Now emit the branch/jump
if (block.exit_instr.type == ControlFlowType::Branch) {
// Check if this is a branch-and-link (bgezal/bltzal)
@@ -2392,8 +2393,8 @@ std::string CodeGenerator::generate_file(
ss << " * addr is the effective (possibly unaligned) address.\n";
ss << " * rt_value is the current value of the destination register.\n";
ss << " */\n";
ss << "static uint32_t psx_lwl(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n";
ss << " uint32_t word = cpu->read_word(addr & ~3u);\n";
ss << "static uint32_t psx_lwl(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n";
ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n";
ss << " switch (addr & 3u) {\n";
ss << " case 0: return (rt_value & 0x00FFFFFFu) | (word << 24);\n";
ss << " case 1: return (rt_value & 0x0000FFFFu) | (word << 16);\n";
@@ -2406,8 +2407,8 @@ std::string CodeGenerator::generate_file(
ss << " * addr is the effective (possibly unaligned) address.\n";
ss << " * rt_value is the current value of the destination register.\n";
ss << " */\n";
ss << "static uint32_t psx_lwr(CPUState* cpu, uint32_t addr, uint32_t rt_value) {\n";
ss << " uint32_t word = cpu->read_word(addr & ~3u);\n";
ss << "static uint32_t psx_lwr(CPUState* cpu, uint32_t addr, uint32_t rt_value, uint32_t rt, uint32_t reg_mask) {\n";
ss << " uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, reg_mask); /* full load interlock */\n";
ss << " switch (addr & 3u) {\n";
ss << " case 0: return word;\n";
ss << " case 1: return (rt_value & 0xFF000000u) | (word >> 8);\n";
+30 -27
View File
@@ -375,6 +375,19 @@ bool FullFunctionEmitter::emit_function(
const bool per_insn_cycles = bios_cycle_per_insn();
// Per-instruction R3000A load-delay interlock (cycle_per_insn mode): §1 base +
// GPR_DEPRES + DO_LDS for one instruction, emitted BEFORE its body so §1 precedes
// any muldiv/GTE deadline stall (Beetle order). CPU loads (op 0x20-0x26) are
// SKIPPED — psx_cyc_load_* runs their full interlock inside the body. The dep/res
// mask is a gen-time literal. Replaces the old flat per-instruction +1.
auto emit_insn_interlock = [&](uint32_t w) {
if (!per_insn_cycles) return;
uint32_t op = w >> 26;
if (op >= 0x20u && op <= 0x26u) return; // CPU load: interlock inside psx_cyc_load_*
out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_cyc_step(cpu, 0x{:X}u);\n#endif\n",
psx_cyc_dep_res_mask(w));
};
for (auto it = addr_to_raw.begin(); it != addr_to_raw.end(); ++it) {
uint32_t addr = it->first;
uint32_t raw = it->second;
@@ -422,9 +435,7 @@ bool FullFunctionEmitter::emit_function(
// running cycle count is exact at MULT/DIV/MFLO/MFHI (and later GTE) for
// the completion-stall to absorb correctly. Orphaned (out-of-function)
// delay slots are inlined elsewhere and charged at those sites.
if (per_insn_cycles) {
out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_advance_cycles(1u);\n#endif\n";
}
emit_insn_interlock(raw);
// Decode and translate.
PSXRecomp::DecodedInstruction d = PSXRecomp::MipsDecoder::decode(raw, addr);
@@ -547,13 +558,11 @@ bool FullFunctionEmitter::emit_function(
if (ds_tr.supported && !ds_tr.is_terminator) {
out += fmt::format(" /* DELAY (orphaned) 0x{:08X}: {:08X} {} */\n",
ds_addr, ds_raw, ds_tr.comment);
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
// Orphaned delay slot is inlined here (not an
// addr_to_raw entry), so charge its cycle directly
// in per-instruction mode (block mode already counts
// it in the owning block_cycles).
if (per_insn_cycles)
out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_advance_cycles(1u);\n#endif\n";
}
}
// Resolve branch. Target was decoded as absolute virtual addr.
@@ -584,13 +593,11 @@ bool FullFunctionEmitter::emit_function(
if (ds_tr.supported && !ds_tr.is_terminator) {
out += fmt::format(" /* DELAY (orphaned) 0x{:08X}: {:08X} {} */\n",
ds_addr, ds_raw, ds_tr.comment);
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
// Orphaned delay slot is inlined here (not an
// addr_to_raw entry), so charge its cycle directly
// in per-instruction mode (block mode already counts
// it in the owning block_cycles).
if (per_insn_cycles)
out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_advance_cycles(1u);\n#endif\n";
}
}
uint32_t target = relocate_j_target(addr, tr.terminator_target);
@@ -610,13 +617,11 @@ bool FullFunctionEmitter::emit_function(
if (ds_tr.supported && !ds_tr.is_terminator) {
out += fmt::format(" /* DELAY (orphaned) 0x{:08X}: {:08X} {} */\n",
ds_addr, ds_raw, ds_tr.comment);
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
// Orphaned delay slot is inlined here (not an
// addr_to_raw entry), so charge its cycle directly
// in per-instruction mode (block mode already counts
// it in the owning block_cycles).
if (per_insn_cycles)
out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_advance_cycles(1u);\n#endif\n";
}
}
uint32_t target = relocate_j_target(addr, tr.terminator_target);
@@ -653,13 +658,11 @@ bool FullFunctionEmitter::emit_function(
if (ds_tr.supported && !ds_tr.is_terminator) {
out += fmt::format(" /* DELAY (orphaned) 0x{:08X}: {:08X} {} */\n",
ds_addr, ds_raw, ds_tr.comment);
// Orphaned delay slot is inlined here (not an addr_to_raw
// entry), so charge its interlock directly, BEFORE its body
// (block mode already counts it in the owning block_cycles).
emit_insn_interlock(ds_raw);
out += fmt::format(" {}\n", ds_tr.c_code);
// Orphaned delay slot is inlined here (not an
// addr_to_raw entry), so charge its cycle directly
// in per-instruction mode (block mode already counts
// it in the owning block_cycles).
if (per_insn_cycles)
out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_advance_cycles(1u);\n#endif\n";
}
}
uint8_t rs = (raw >> 21) & 0x1F;
+37 -29
View File
@@ -847,15 +847,16 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs; /* GPR_DEP rs; dest rt armed via LDWhich */
r.supported = true;
const std::string addr_expr = fmt::format(
"(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast<int>(rs), simm);
if (rt == 0) {
r.c_code = fmt::format("(void)cpu->read_byte({});", addr_expr);
r.c_code = fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr_expr, mask);
} else {
r.c_code = fmt::format(
"cpu->gpr[{}] = (uint32_t)(int32_t)(int8_t)cpu->read_byte({});",
static_cast<int>(rt), addr_expr);
"cpu->gpr[{}] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);",
static_cast<int>(rt), addr_expr, static_cast<int>(rt), mask);
}
r.comment = fmt::format("lb {}, {}({})", gpr_name(rt), simm, gpr_name(rs));
return r;
@@ -906,17 +907,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs; /* GPR_DEP rs (LWL does not GPR_DEP rt) */
r.supported = true;
const std::string body = (rt == 0)
? "(void)cpu->read_word(psx_aligned);"
? fmt::format("(void)psx_cyc_load_word(cpu, psx_aligned, 0, 0x{:X}u);", mask)
: fmt::format(
"uint32_t psx_byte_offset = psx_addr & 3u; "
"uint32_t psx_shift_left = (3u - psx_byte_offset) * 8u; "
"uint32_t psx_keep_mask = (1u << psx_shift_left) - 1u; "
"uint32_t psx_word = cpu->read_word(psx_aligned); "
"uint32_t psx_word = psx_cyc_load_word(cpu, psx_aligned, {}, 0x{:X}u); "
"uint32_t psx_old_rt = cpu->gpr[{}]; "
"cpu->gpr[{}] = (psx_old_rt & psx_keep_mask) | (psx_word << psx_shift_left);",
static_cast<int>(rt), static_cast<int>(rt));
static_cast<int>(rt), mask, static_cast<int>(rt), static_cast<int>(rt));
r.c_code = fmt::format(
"{{ uint32_t psx_addr = (uint32_t)((int32_t)cpu->gpr[{}] + ({})); "
"uint32_t psx_aligned = psx_addr & ~3u; "
@@ -931,17 +933,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs; /* GPR_DEP rs (LWR does not GPR_DEP rt) */
r.supported = true;
const std::string body = (rt == 0)
? "(void)cpu->read_word(psx_aligned);"
? fmt::format("(void)psx_cyc_load_word(cpu, psx_aligned, 0, 0x{:X}u);", mask)
: fmt::format(
"uint32_t psx_byte_offset = psx_addr & 3u; "
"uint32_t psx_shift_right = psx_byte_offset * 8u; "
"uint32_t psx_keep_mask = ~(0xFFFFFFFFu >> psx_shift_right); "
"uint32_t psx_word = cpu->read_word(psx_aligned); "
"uint32_t psx_word = psx_cyc_load_word(cpu, psx_aligned, {}, 0x{:X}u); "
"uint32_t psx_old_rt = cpu->gpr[{}]; "
"cpu->gpr[{}] = (psx_old_rt & psx_keep_mask) | (psx_word >> psx_shift_right);",
static_cast<int>(rt), static_cast<int>(rt));
static_cast<int>(rt), mask, static_cast<int>(rt), static_cast<int>(rt));
r.c_code = fmt::format(
"{{ uint32_t psx_addr = (uint32_t)((int32_t)cpu->gpr[{}] + ({})); "
"uint32_t psx_aligned = psx_addr & ~3u; "
@@ -956,11 +959,12 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs;
r.supported = true;
const std::string body = (rt == 0)
? "(void)cpu->read_half(psx_addr);"
: fmt::format("cpu->gpr[{}] = (uint32_t)(int32_t)(int16_t)cpu->read_half(psx_addr);",
static_cast<int>(rt));
? fmt::format("(void)psx_cyc_load_half(cpu, psx_addr, 0, 0x{:X}u);", mask)
: fmt::format("cpu->gpr[{}] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, psx_addr, {}, 0x{:X}u);",
static_cast<int>(rt), static_cast<int>(rt), mask);
r.c_code = fmt::format(
"{{ uint32_t psx_addr = (uint32_t)((int32_t)cpu->gpr[{}] + ({})); "
"if (psx_addr & 1u) {{ psx_unaligned_access(cpu, psx_addr, 0x{:08X}u); return; }} "
@@ -975,10 +979,12 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs;
r.supported = true;
const std::string body = (rt == 0)
? "(void)cpu->read_word(psx_addr);"
: fmt::format("cpu->gpr[{}] = cpu->read_word(psx_addr);", static_cast<int>(rt));
? fmt::format("(void)psx_cyc_load_word(cpu, psx_addr, 0, 0x{:X}u);", mask)
: fmt::format("cpu->gpr[{}] = psx_cyc_load_word(cpu, psx_addr, {}, 0x{:X}u);",
static_cast<int>(rt), static_cast<int>(rt), mask);
r.c_code = fmt::format(
"{{ uint32_t psx_addr = (uint32_t)((int32_t)cpu->gpr[{}] + ({})); "
"if (psx_addr & 3u) {{ psx_unaligned_access(cpu, psx_addr, 0x{:08X}u); return; }} "
@@ -993,15 +999,16 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs;
r.supported = true;
const std::string addr_expr = fmt::format(
"(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast<int>(rs), simm);
if (rt == 0) {
r.c_code = fmt::format("(void)cpu->read_byte({});", addr_expr);
r.c_code = fmt::format("(void)psx_cyc_load_byte(cpu, {}, 0, 0x{:X}u);", addr_expr, mask);
} else {
r.c_code = fmt::format(
"cpu->gpr[{}] = (uint32_t)cpu->read_byte({});",
static_cast<int>(rt), addr_expr);
"cpu->gpr[{}] = (uint32_t)psx_cyc_load_byte(cpu, {}, {}, 0x{:X}u);",
static_cast<int>(rt), addr_expr, static_cast<int>(rt), mask);
}
r.comment = fmt::format("lbu {}, {}({})", gpr_name(rt), simm, gpr_name(rs));
return r;
@@ -1012,10 +1019,12 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
const uint8_t rs = (d.raw >> 21) & 0x1F;
const uint8_t rt = (d.raw >> 16) & 0x1F;
const int32_t simm = static_cast<int32_t>(static_cast<int16_t>(d.raw & 0xFFFF));
const uint32_t mask = 1u << rs;
r.supported = true;
const std::string body = (rt == 0)
? "(void)cpu->read_half(psx_addr);"
: fmt::format("cpu->gpr[{}] = (uint32_t)cpu->read_half(psx_addr);", static_cast<int>(rt));
? fmt::format("(void)psx_cyc_load_half(cpu, psx_addr, 0, 0x{:X}u);", mask)
: fmt::format("cpu->gpr[{}] = (uint32_t)psx_cyc_load_half(cpu, psx_addr, {}, 0x{:X}u);",
static_cast<int>(rt), static_cast<int>(rt), mask);
r.c_code = fmt::format(
"{{ uint32_t psx_addr = (uint32_t)((int32_t)cpu->gpr[{}] + ({})); "
"if (psx_addr & 1u) {{ psx_unaligned_access(cpu, psx_addr, 0x{:08X}u); return; }} "
@@ -1315,22 +1324,21 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
// Faithful GTE: COP2 reg write stalls to the command deadline.
const std::string gte_stall =
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n ";
// §1+DO_LDS charged by full_function_emitter's psx_cyc_step (op 0x32 is
// non-load); the GTE deadline stall by psx_gte_stall; psx_cyc_lwc2_read does
// the ReadMemory timing (completion +1, no LDWhich arm — dest is a GTE reg).
std::string addr = offset == 0
? fmt::format("cpu->gpr[{}]", static_cast<int>(rs))
: fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast<int>(rs), static_cast<int>(offset));
bool special = (rt == 7 || (rt >= 8 && rt <= 11) || rt == 14 || rt == 15 || rt == 28 || rt == 30);
if (special) {
std::string addr = offset == 0
? fmt::format("cpu->gpr[{}]", static_cast<int>(rs))
: fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast<int>(rs), static_cast<int>(offset));
r.c_code = gte_stall + fmt::format(
"gte_write_data(cpu, {}, cpu->read_word({}));",
"gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {}));",
static_cast<int>(rt), addr);
} else if (offset == 0) {
r.c_code = gte_stall + fmt::format(
"cpu->gte_data[{}] = cpu->read_word(cpu->gpr[{}]);",
static_cast<int>(rt), static_cast<int>(rs));
} else {
r.c_code = gte_stall + fmt::format(
"cpu->gte_data[{}] = cpu->read_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})));",
static_cast<int>(rt), static_cast<int>(rs), static_cast<int>(offset));
"cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {});",
static_cast<int>(rt), addr);
}
r.comment = fmt::format("lwc2 gte[{}], {}({})",
rt, static_cast<int>(offset), gpr_name(rs));
+20
View File
@@ -70,6 +70,20 @@ typedef struct CPUState {
* stalls until it — faithful R3000A behavior (Beetle gte_ts_done).
* Appended at END so prior field offsets are unchanged. */
uint64_t gte_ts_done;
/* ---- R3000A load-delay pipeline interlock (Beetle ReadAbsorb/ReadFudge/
* LDAbsorb/LDWhich) — TIMING ONLY; the load VALUE delay is handled by the
* existing load-delay correctness path. See psx_cyc.h + accuracy/
* load_readfudge_ldabsorb.md. Models: a load's data-access cost becomes a
* per-register "give-back" (read_absorb) that following instructions consume
* instead of charging their own +1 base; plus the +2 "fudge" charged on a
* load whose predecessor committed no load. Appended at END so prior field
* offsets are unchanged (precompiled overlay DLLs bake offsets). */
uint8_t read_absorb[33]; /* ReadAbsorb[0..31] + [32]=0x20 DO_LDS dummy slot */
uint8_t read_absorb_which; /* ReadAbsorbWhich: GPR the last committed load wrote */
uint8_t read_fudge; /* ReadFudge: last committed load's dest reg, or 0x20 = none */
uint8_t ld_which_t; /* LDWhich (timing): pending load dest reg, 0x20 = none */
uint32_t ld_absorb; /* LDAbsorb: pending load's give-back (region+completion) */
} CPUState;
/* Trap trampolines — defined in runtime/src/traps.c */
@@ -225,4 +239,10 @@ static inline int psx_call_contract(CPUState* cpu, uint32_t site_ra,
}
#endif
/* R3000A load-delay interlock helpers (psx_cyc.h). Included LAST so CPUState is
* fully defined first; psx_cyc.h's own #include "cpu_state.h" is a guarded no-op.
* This makes the per-instruction timing API visible to EVERY generated file and
* runtime TU that includes cpu_state.h. */
#include "psx_cyc.h"
#endif /* PSXRECOMP_CPU_STATE_H */
+100
View File
@@ -0,0 +1,100 @@
/* psx_cyc.h — R3000A load-delay pipeline interlock (ReadAbsorb / ReadFudge /
* LDAbsorb / LDWhich). TIMING ONLY — the load VALUE delay is handled by the
* existing load-delay correctness path; this models only the CPU cycle cost.
*
* FAITHFUL_TIMING_PLAN.md / accuracy/load_readfudge_ldabsorb.md. Transcribed
* (facts, not code) from the in-tree Beetle oracle
* psxrecomp/beetle-psx/mednafen/psx/cpu.cpp (RunReal §1 base 795-798, GPR_DEPRES
* 702-705, DO_LDS 800, ReadMemory 364-451) and libretro.cpp (main-RAM read wait
* = +3, libretro.cpp:884). Every backend — the dirty-RAM interpreter and BOTH
* static emitters (code_generator.cpp, full_function_emitter.cpp/strict_translator.cpp)
* — accounts guest CPU cycles THROUGH THESE HELPERS so they can never disagree.
*
* Model (per retired instruction, in program order):
* §1 base : if(ReadAbsorb[ReadAbsorbWhich]) ReadAbsorb[ReadAbsorbWhich]--;
* else timestamp++; -- a pending load's give-back is
* consumed INSTEAD of the +1 base (pipeline write-back overlap).
* deps : for each GPR this opcode reads/writes, ReadAbsorb[reg]=0 (ends a
* give-back when the loaded value is used). ReadAbsorb[0] preserved.
* DO_LDS : commit the PREVIOUS instruction's pending load TIMING:
* ReadAbsorb[LDWhich]=LDAbsorb; ReadFudge=LDWhich; ...; LDWhich=0x20.
* load only : ReadMemory — clear the current give-back slot, charge the +2 fudge
* iff the predecessor committed no load (ReadFudge==0x20), charge the
* region wait (main RAM +3) + completion (+2 CPU / +1 LWC2); the
* (region+completion) becomes this load's LDAbsorb give-back, armed on
* LDWhich=rt for the NEXT instruction's DO_LDS to commit.
*/
#ifndef PSX_CYC_H
#define PSX_CYC_H
#include <stdint.h>
#include "cpu_state.h" /* CPUState (guard-safe: cpu_state.h includes us last) */
#ifdef __cplusplus
extern "C" {
#endif
extern void psx_advance_cycles(uint32_t cycles);
/* §1 base (Beetle cpu.cpp:795-798). */
static inline void psx_cyc_base(CPUState* cpu) {
uint8_t w = cpu->read_absorb_which;
if (cpu->read_absorb[w]) cpu->read_absorb[w]--;
else psx_advance_cycles(1u);
}
/* GPR_DEPRES (Beetle cpu.cpp:702-705): zero ReadAbsorb[n] for every source/dest
* GPR of this instruction, preserving ReadAbsorb[0] (skipping bit 0 == Beetle's
* save/restore of ReadAbsorb[0]). */
static inline void psx_cyc_deps(CPUState* cpu, uint32_t reg_mask) {
reg_mask &= 0xFFFFFFFEu; /* never touch ReadAbsorb[0] */
while (reg_mask) {
unsigned n = (unsigned)__builtin_ctz(reg_mask);
cpu->read_absorb[n] = 0u;
reg_mask &= reg_mask - 1u;
}
}
/* DO_LDS timing-commit (Beetle cpu.cpp:800). LDWhich==0x20 (no pending) writes the
* dummy slot read_absorb[32] and sets read_fudge=0x20 (=> next load gets +2 fudge). */
static inline void psx_cyc_lds(CPUState* cpu) {
uint8_t lw = cpu->ld_which_t;
cpu->read_absorb[lw] = (uint8_t)cpu->ld_absorb;
cpu->read_fudge = lw;
cpu->read_absorb_which = (uint8_t)(cpu->read_absorb_which | (lw & 0x1Fu));
cpu->ld_which_t = 0x20u;
}
/* Full per-instruction interlock for a NON-CPU-load instruction (ALU, shift,
* branch, jump, store, COP control, LWC2/SWC2 pre-step, mult/div, mfhi/mflo, ...).
* reg_mask from psx_cyc_dep_res_mask(). MUST be emitted BEFORE the instruction
* body so §1 precedes any muldiv/GTE deadline stall in the body (Beetle order). */
static inline void psx_cyc_step(CPUState* cpu, uint32_t reg_mask) {
psx_cyc_base(cpu);
psx_cyc_deps(cpu, reg_mask);
psx_cyc_lds(cpu);
}
/* The GPR dep+res bitmask used by psx_cyc_step lives in psx_instr_cost.h
* (psx_cyc_dep_res_mask) — a standalone pure function shared by the emitters
* (gen-time literal) and the interpreter (runtime), with no CPUState dependency. */
/* CPU data load value+timing (memory.c). Does the full Beetle per-instruction
* sequence: §1 base + GPR_DEPRES(reg_mask) + (LDWhich==rt cancel) + DO_LDS +
* ReadMemory timing (clear give-back, fudge, region+completion, arm LDAbsorb +
* LDWhich=rt), and returns the raw value (caller applies the same width/sign as the
* prior cpu->read_* call). For LWL/LWR pass the WORD-ALIGNED address. */
extern uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask);
extern uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask);
extern uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask);
/* LWC2 (GTE load) ReadMemory timing only (completion +1, NO LDWhich arm — the dest
* is a GTE register). Call AFTER psx_cyc_step(cpu,0) (§1+DO_LDS) and psx_gte_stall.
* Returns the raw 32-bit value. */
extern uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr);
#ifdef __cplusplus
}
#endif
#endif /* PSX_CYC_H */
+59 -8
View File
@@ -36,14 +36,16 @@ extern "C" {
* each component verified against Beetle via the cyc_watch DELTA gate
* (FAITHFUL_TIMING_PLAN.md §3c). Both backends consume this one function.
*
* DATA-MEMORY ACCESS IS *NOT* CHARGED HERE — it lives in the memory path.
* The runtime already owns the load/store data-access cost address-keyed in
* runtime/src/memory.c (charge_main_ram_read → psx_guest_read_*: +6 cyc per
* main-RAM read wait-state, scratchpad/MMIO/ROM excluded), exactly like Beetle
* charges it in PS_CPU::ReadMemory (region wait + completion), NOT in the
* opcode dispatch. The opcode word alone cannot know the target region
* (RAM vs scratchpad vs MMIO), so a per-opcode load surcharge here is wrong by
* construction and DOUBLE-COUNTS what memory.c already charges.
* DATA-MEMORY ACCESS IS *NOT* CHARGED HERE — it lives in the load-delay interlock.
* Guest CPU data loads route through psx_cyc_load_* (runtime/src/memory.c, declared
* in psx_cyc.h), which charges the Beetle ReadMemory cost address-keyed (main RAM
* region +3, completion +2, scratchpad +0) as the LDAbsorb give-back, plus the +2
* ReadFudge — exactly like PS_CPU::ReadMemory, NOT in the opcode dispatch. The
* opcode word alone cannot know the target region (RAM vs scratchpad vs MMIO), so a
* per-opcode load surcharge HERE is wrong by construction and would double-count.
* NOTE: in per-instruction mode (the default) the §1 base is charged by psx_cyc_base
* (psx_cyc.h), not by this function — psx_instr_base_cycles is only the block-mode
* (PSX_CODEGEN_CYCLE_PER_INSN=0) fallback sum and the dep/res mask seed below.
*
* HISTORY: Stage-2 #1a (commit 2ef47bd) returned 3 for loads / 2 for LWC2
* ("1 execute + 2 data-access"). The BIOS-kernel cycle ruler (region
@@ -59,6 +61,55 @@ static inline uint32_t psx_instr_base_cycles(uint32_t insn) {
return 1u; /* pure CPU execute base; data access charged in memory.c */
}
/* GPR dep+res bitmask (bit n => GPR n) for one MIPS-I instruction, transcribed
* from the Beetle oracle's per-opcode GPR_DEP/GPR_RES (cpu.cpp). Drives the
* load-delay interlock's GPR_DEPRES step (psx_cyc_step / psx_cyc_load_*, psx_cyc.h)
* — the give-back ends when a following instruction reads/writes the loaded reg.
* For CPU loads this returns the source (rs) ONLY: the dest rt is the delayed-load
* target (armed via LDWhich), not a GPR_RES. Pure (insn-only) so the static emitters
* fold it to a literal at gen-time and the interpreter calls it at runtime. */
static inline uint32_t psx_cyc_dep_res_mask(uint32_t insn) {
uint32_t op = insn >> 26;
uint32_t rs = (insn >> 21) & 0x1Fu;
uint32_t rt = (insn >> 16) & 0x1Fu;
uint32_t rd = (insn >> 11) & 0x1Fu;
uint32_t M_rs = 1u << rs, M_rt = 1u << rt, M_rd = 1u << rd;
switch (op) {
case 0x00: { /* SPECIAL */
uint32_t fn = insn & 0x3Fu;
switch (fn) {
case 0x00: case 0x02: case 0x03: return M_rt | M_rd; /* SLL/SRL/SRA */
case 0x04: case 0x06: case 0x07: return M_rs | M_rt | M_rd; /* SLLV/SRLV/SRAV */
case 0x08: case 0x09: return M_rs | M_rd; /* JR/JALR */
case 0x10: case 0x12: return M_rd; /* MFHI/MFLO */
case 0x11: case 0x13: return M_rs; /* MTHI/MTLO */
case 0x18: case 0x19: case 0x1A: case 0x1B: return M_rs | M_rt; /* MULT/MULTU/DIV/DIVU */
case 0x20: case 0x21: case 0x22: case 0x23:
case 0x24: case 0x25: case 0x26: case 0x27:
case 0x2A: case 0x2B: return M_rs | M_rt | M_rd; /* ADD..NOR/SLT/SLTU */
default: return 0u; /* SYSCALL/BREAK/... */
}
}
case 0x01: { /* REGIMM/BCOND: DEP rs, RES link(31 for *AL variants) */
uint32_t link = ((rt & 0x1Eu) == 0x10u) ? (1u << 31) : 0u;
return M_rs | link;
}
case 0x02: return 0u; /* J */
case 0x03: return 1u << 31; /* JAL (RES 31) */
case 0x04: case 0x05: return M_rs | M_rt; /* BEQ/BNE */
case 0x06: case 0x07: return M_rs; /* BLEZ/BGTZ */
case 0x08: case 0x09: case 0x0A: case 0x0B:
case 0x0C: case 0x0D: case 0x0E: return M_rs | M_rt; /* ADDI..XORI */
case 0x0F: return M_rt; /* LUI (RES rt) */
case 0x20: case 0x21: case 0x22: case 0x23:
case 0x24: case 0x25: case 0x26: return M_rs; /* LB..LWR (load: rs only) */
case 0x28: case 0x29: case 0x2A: case 0x2B:
case 0x2E: return M_rs | M_rt; /* SB/SH/SWL/SW/SWR */
/* COP0/1/2/3 (0x10-0x13), LWC* (0x30-0x33), SWC* (0x38-0x3B): no GPR_DEP/RES */
default: return 0u;
}
}
#ifdef __cplusplus
}
#endif
+38 -31
View File
@@ -369,8 +369,9 @@ void dirty_ram_log_marker(uint32_t addr, uint32_t tag, int kind) {
e->transferred = (uint8_t)(200 + kind);
}
static inline uint32_t interp_lwl(CPUState *cpu, uint32_t addr, uint32_t rt_value) {
uint32_t word = cpu->read_word(addr & ~3u);
/* LWL/LWR are merge-only here; the timed aligned-word read is done by the caller via
* psx_cyc_load_word (Beetle reads the aligned word; GPR_DEP rs only, arms LDWhich=rt). */
static inline uint32_t lwl_merge(uint32_t addr, uint32_t word, uint32_t rt_value) {
switch (addr & 3u) {
case 0: return (rt_value & 0x00FFFFFFu) | (word << 24);
case 1: return (rt_value & 0x0000FFFFu) | (word << 16);
@@ -379,8 +380,7 @@ static inline uint32_t interp_lwl(CPUState *cpu, uint32_t addr, uint32_t rt_valu
}
}
static inline uint32_t interp_lwr(CPUState *cpu, uint32_t addr, uint32_t rt_value) {
uint32_t word = cpu->read_word(addr & ~3u);
static inline uint32_t lwr_merge(uint32_t addr, uint32_t word, uint32_t rt_value) {
switch (addr & 3u) {
case 0: return word;
case 1: return (rt_value & 0xFF000000u) | (word >> 8);
@@ -899,17 +899,10 @@ static void exec_delay_slot(CPUState *cpu, uint32_t pc) {
uint32_t dummy_next = 0;
(void)exec_one(cpu, pc, &dummy_next);
g_dirty_ram_insns_run++;
/* CYCLE MODEL: the delay-slot instruction is a real retired R3000A
* instruction and costs its own cycle. The main interp loop charges 1 for
* the branch/jump itself; charge 1 here for the delay slot so a branch+slot
* 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).
* Charged through the single-source cost fn (psx_instr_cost.h). */
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(psx_instr_base_cycles(insn));
#endif
/* CYCLE MODEL: the delay-slot instruction is a real retired R3000A instruction
* and is charged its own per-instruction interlock INSIDE exec_one (top-of-fn
* §1+deps+DO_LDS, or psx_cyc_load_* for a load delay slot) — so a branch+slot
* pair costs both, matching hardware. No separate charge here. */
}
static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
@@ -927,6 +920,17 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
*next_pc_out = pc + 4;
/* Per-instruction R3000A load-delay interlock (single-source: psx_cyc.h, shared
* with both static emitters). §1 base + GPR_DEPRES + DO_LDS run HERE, before the
* instruction body, so §1 precedes any muldiv/GTE deadline stall the body applies
* (Beetle order). CPU loads (op 0x20-0x26) are skipped here — psx_cyc_load_* runs
* their full interlock inside the body (and arms LDWhich=rt). This replaces the
* old flat per-instruction psx_advance_cycles(psx_instr_base_cycles). */
#ifdef PSX_ENABLE_BLOCK_CYCLES
if (!(opc >= 0x20u && opc <= 0x26u))
psx_cyc_step(cpu, psx_cyc_dep_res_mask(insn));
#endif
/* Update last-store PC tracker so SIO PC tracer attribution stays
* coherent through interpreted stubs. */
g_debug_last_store_pc = pc;
@@ -1373,45 +1377,52 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
}
return abort_unsupported(pc, insn, "COP2 op");
}
/* CPU loads: the full per-instruction R3000A interlock (§1 + GPR_DEPRES(rs) +
* DO_LDS + ReadMemory timing + arm LDWhich=rt) lives in psx_cyc_load_*; exec_one's
* top-of-function step is skipped for op 0x20-0x26 so it is not double-charged.
* #ifndef PSX_ENABLE_BLOCK_CYCLES these still read the value via the uncharged
* accessor (psx_cyc_load_* falls back to a plain read when cycles are off). */
case 0x20: { /* LB rt, simm(rs) */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)cpu->read_byte(addr);
cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs);
cpu->gpr[0] = 0;
return 0;
}
case 0x21: { /* LH */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)cpu->read_half(addr);
cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs);
cpu->gpr[0] = 0;
return 0;
}
case 0x22: { /* LWL */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = interp_lwl(cpu, addr, cpu->gpr[rt]);
uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs);
cpu->gpr[rt] = lwl_merge(addr, word, cpu->gpr[rt]);
cpu->gpr[0] = 0;
return 0;
}
case 0x23: { /* LW */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = cpu->read_word(addr);
cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs);
cpu->gpr[0] = 0;
return 0;
}
case 0x24: { /* LBU */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = (uint32_t)cpu->read_byte(addr);
cpu->gpr[rt] = (uint32_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs);
cpu->gpr[0] = 0;
return 0;
}
case 0x25: { /* LHU */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = (uint32_t)cpu->read_half(addr);
cpu->gpr[rt] = (uint32_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs);
cpu->gpr[0] = 0;
return 0;
}
case 0x26: { /* LWR */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
cpu->gpr[rt] = interp_lwr(cpu, addr, cpu->gpr[rt]);
uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs);
cpu->gpr[rt] = lwr_merge(addr, word, cpu->gpr[rt]);
cpu->gpr[0] = 0;
return 0;
}
@@ -1448,12 +1459,12 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
interp_swr(cpu, addr, cpu->gpr[rt]);
return 0;
}
case 0x32: { /* LWC2 */
case 0x32: { /* LWC2 — §1+DO_LDS charged by exec_one's top step (mask 0) */
uint32_t addr = cpu->gpr[rs] + (uint32_t)simm;
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_gte_stall(cpu); /* COP2 reg write stalls to GTE completion */
#endif
gte_write_data(cpu, (uint8_t)rt, cpu->read_word(addr));
gte_write_data(cpu, (uint8_t)rt, psx_cyc_lwc2_read(cpu, addr));
return 0;
}
case 0x3A: { /* SWC2 */
@@ -1624,10 +1635,7 @@ static void psx_run_precise(CPUState *cpu, uint32_t bcyc, int deadline_entry) {
for (int i = 0; i < MAX_PRECISE_INSNS; i++) {
uint32_t next_pc = 0;
g_unsupported_seen = 0;
int transferred = exec_one(cpu, pc, &next_pc);
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(psx_instr_base_cycles(fetch_word(pc & 0x1FFFFFFFu)));
#endif
int transferred = exec_one(cpu, pc, &next_pc); /* charges its own interlock */
g_dirty_ram_insns_run++;
if (g_unsupported_seen) {
/* Valid compiled code always decodes; if not, hand the committed PC to
@@ -1909,9 +1917,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_
g_ra_load_snap_srcaddr = 0;
}
}
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_advance_cycles(psx_instr_base_cycles(insn)); /* single-source cost */
#endif
/* Per-instruction cycle cost (R3000A load-delay interlock) is charged
* INSIDE exec_one (top-of-fn §1+deps+DO_LDS, or psx_cyc_load_* for loads). */
dirty_ram_log_instruction(cpu, pc, insn, before_s0, next_pc,
transferred ? cpu->pc : next_pc,
transferred);
+10 -4
View File
@@ -2642,13 +2642,19 @@ int main(int argc, char** argv) {
/* Initialize CPU state. */
CPUState cpu;
std::memset(&cpu, 0, sizeof(cpu));
/* R3000A load-delay interlock init (Beetle: BACKED_LDWhich=0x20 = no pending
* load; ReadFudge=0 so the first load gets no fudge). Rest is correctly 0. */
cpu.ld_which_t = 0x20;
/* Wire memory function pointers. */
cpu.read_word = psx_guest_read_word; /* +6cyc main-RAM read wait states */
/* Wire memory function pointers. Guest data loads now route their CPU cycle
* cost through the faithful load-delay interlock (psx_cyc_load_* in memory.c),
* so these VALUE accessors are the UNCHARGED psx_read_* — the data-access
* wait-state is charged once, inside the interlock, never here. */
cpu.read_word = psx_read_word;
cpu.write_word = psx_write_word;
cpu.read_half = psx_guest_read_half;
cpu.read_half = psx_read_half;
cpu.write_half = psx_write_half;
cpu.read_byte = psx_guest_read_byte;
cpu.read_byte = psx_read_byte;
cpu.write_byte = psx_write_byte;
/* Wire the sljit JIT host-helper table (cpu-relative => position-independent
* shards; prerequisite for the persisted sljit shard cache). Harmless when
+82 -37
View File
@@ -853,49 +853,94 @@ uint8_t psx_read_byte(uint32_t addr) {
return 0;
}
/* ---- CPU guest-side data reads: model PS1 main-RAM read latency ----
* The R3000A has no usable D-cache (its data cache is repurposed as the 1 KB
* scratchpad), so every CPU data read from main DRAM costs the base opcode cycle
* plus a data-access wait-state. The base opcode cycle is charged per instruction
* by the emitter/interp (psx_instr_base_cycles); here we add ONLY the data-access
* wait-state, and only for main-RAM targets — scratchpad, MMIO, BIOS ROM and
* open-bus are excluded (they have their own access timing). Keyed on the runtime
* effective physical address (all of KUSEG/KSEG0/KSEG1 alias the same DRAM), NOT
* the code region, so relocated kernel code is timed correctly.
/* ---- CPU guest-side data loads: faithful R3000A load-delay pipeline interlock ----
* The R3000A has no usable D-cache (it is repurposed as the 1 KB scratchpad), so a
* CPU data load from main DRAM stalls the pipeline. Beetle (cpu.cpp ReadMemory,
* 364-451) models this as: a +2 "fudge" iff the predecessor committed no load, the
* region wait (main RAM = +3, libretro.cpp:884), and a completion cost (+2 CPU /
* +1 LWC2); the (region+completion) becomes a per-register LDAbsorb "give-back" that
* following instructions consume instead of their own +1 base (pipeline write-back
* overlap). The §1 base + GPR_DEPRES + DO_LDS that bracket this run in psx_cyc.h.
*
* VALUE = 4, the Beetle ORACLE-accurate cost (the cycle test ROM load-isolation
* loop measures a main-RAM load at +5 over baseline = base 1 + 4; Beetle ReadMemory
* = fudge(0/2) + region wait(0 RAM) + completion(+2), netting 4 in the common fudged
* case). The old 6 came from DuckStation (RAM_READ_TICKS), which our oracle
* contradicts. Ruler #2 load == Beetle EXACTLY at 4.
* These functions own the WHOLE per-instruction interlock for a CPU load (they call
* psx_cyc_base/deps/lds), so the emitters/interp invoke them in place of the prior
* cpu->read_* call and emit NO separate psx_cyc_step for the load. They return the
* raw value via the UNCHARGED psx_read_* (cpu->read_* is now uncharged too — the
* data-access cost is charged exactly once, here). Keyed on the runtime effective
* physical address (KUSEG/KSEG0/KSEG1 alias the same DRAM).
*
* ⚠ KNOWN ISSUE this value CAUSES (under active root-cause on branch
* wt/tomba2-load-accuracy): lowering 6→4 DETERMINISTICALLY WEDGES Tomba 2's
* BIOS-shell boot — pc=0/exception at shell pc 0xBFC2CE64 (epc 0x80000048),
* reproducible at identical total_checks across restarts. The faster-but-accurate
* load cost makes the CPU outrun a shell loop's timing assumption, exposing a
* downstream bug (likely incomplete device/loop timing). We deliberately KEEP the
* accurate value and fix the real cause rather than mask it with a wrong cost —
* faithful core (CLAUDE.md Rule -1). Until the wedge is fixed, Tomba 2 will not
* reach the FMV on this branch; ruler #2 (its own boot disc) is unaffected.
*
* RESIDUAL beyond the constant (stateful, future): Beetle's per-load ReadFudge
* variation (back-to-back loads differ ~1; ruler load2 was 10 vs 11) and LDAbsorb
* give-back (a load whose result is read by the next instruction is absorbed).
*
* These wrappers are wired to cpu->read_* (see main.cpp), so the penalty applies
* to BOTH statically-recompiled and dirty-RAM-interpreted guest loads, but NOT to
* debug-server / device reads that call psx_read_* directly. */
#define PSX_RAM_READ_WAIT_CYCLES 4u /* oracle-accurate; ⚠ causes Tomba 2 boot wedge @0xBFC2CE64 — root-cause in progress */
* Region residual: only main-RAM (+3) and scratchpad (+0) region waits are modeled;
* MMIO/BIOS-ROM device read waits (e.g. SPU +36) remain a separate unmodeled axis —
* those loads still get the +2 completion (faithful) but region 0. */
extern void psx_advance_cycles(uint32_t cycles);
static inline void charge_main_ram_read(uint32_t addr) {
if ((addr & 0x1FFFFFFFu) < RAM_SIZE) psx_advance_cycles(PSX_RAM_READ_WAIT_CYCLES);
/* Beetle ReadMemory data-access timing (cpu.cpp:369-448), after §1/deps/DO_LDS.
* compl_cost = 2 (CPU load) / 1 (LWC2); arm_rt = GPR to arm as pending load, or
* 0x20 = none (LWC2, dest is a GTE reg). */
static inline void psx_cyc_readmem(CPUState* cpu, uint32_t phys,
uint32_t compl_cost, uint32_t arm_rt) {
/* ReadMemory start (369-370): clear the current give-back slot. */
cpu->read_absorb[cpu->read_absorb_which] = 0u;
cpu->read_absorb_which = 0u;
/* Scratchpad (the D-cache): no wait, no give-back (414-422). */
if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) {
cpu->ld_absorb = 0u;
cpu->ld_which_t = (uint8_t)arm_rt;
return;
}
/* fudge (424): +2 iff the predecessor committed no load (read_fudge==0x20). */
psx_advance_cycles((uint32_t)((cpu->read_fudge >> 4) & 2u));
uint32_t region = (phys < RAM_SIZE) ? 3u : 0u; /* main RAM +3; others: separate axis */
uint32_t cost = region + compl_cost; /* LDAbsorb = region + completion */
cpu->ld_absorb = cost;
psx_advance_cycles(cost);
cpu->ld_which_t = (uint8_t)arm_rt;
}
uint32_t psx_guest_read_word(uint32_t addr) { charge_main_ram_read(addr); return psx_read_word(addr); }
uint16_t psx_guest_read_half(uint32_t addr) { charge_main_ram_read(addr); return psx_read_half(addr); }
uint8_t psx_guest_read_byte(uint32_t addr) { charge_main_ram_read(addr); return psx_read_byte(addr); }
/* The interlock half of a load (§1+deps+(cancel)+DO_LDS+ReadMemory). Gated on
* PSX_ENABLE_BLOCK_CYCLES so the Beetle-oracle build (cycles off) does a plain read. */
static inline void psx_cyc_load_timing(CPUState* cpu, uint32_t addr,
uint32_t rt, uint32_t reg_mask) {
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_cyc_base(cpu);
psx_cyc_deps(cpu, reg_mask);
if (cpu->ld_which_t == rt) cpu->ld_which_t = 0u; /* cancel pending load to same dest */
psx_cyc_lds(cpu);
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 2u, rt);
#else
(void)cpu; (void)addr; (void)rt; (void)reg_mask;
#endif
}
uint32_t psx_cyc_load_word(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
return psx_read_word(addr);
}
uint16_t psx_cyc_load_half(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
return psx_read_half(addr);
}
uint8_t psx_cyc_load_byte(CPUState* cpu, uint32_t addr, uint32_t rt, uint32_t reg_mask) {
psx_cyc_load_timing(cpu, addr, rt, reg_mask);
return psx_read_byte(addr);
}
/* LWC2 (GTE load): §1/DO_LDS done by psx_cyc_step(cpu,0); the GTE deadline stall by
* psx_gte_stall — both emitted before this call. Completion +1, no LDWhich arm. */
uint32_t psx_cyc_lwc2_read(CPUState* cpu, uint32_t addr) {
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_cyc_readmem(cpu, addr & 0x1FFFFFFFu, 1u, 0x20u);
#else
(void)cpu;
#endif
return psx_read_word(addr);
}
/* Deprecated uncharged passthroughs (the +4 flat wait-state model is gone; load
* timing now lives in psx_cyc_load_*). Kept so any stray reference stays valid. */
uint32_t psx_guest_read_word(uint32_t addr) { return psx_read_word(addr); }
uint16_t psx_guest_read_half(uint32_t addr) { return psx_read_half(addr); }
uint8_t psx_guest_read_byte(uint32_t addr) { return psx_read_byte(addr); }
void psx_write_byte(uint32_t addr, uint8_t val) {
if (sr_ptr && (*sr_ptr & 0x10000u)) return;