timing: GTE-read + MFC0 + muldiv-stall load-delay give-back (ruler#2 100%)

Closes the last steady-state cycle divergences; native now == Beetle on
the complete 12-loop ruler #2 suite.

- MFC2/CFC2 (GTE reg -> GPR read): new psx_gte_read stalls to gte_ts_done
  AND hands the stall amount to following instructions as a load-delay
  give-back (Beetle cpu.cpp:1332-1341: LDAbsorb=gte_ts_done-ts,
  LDWhich=rt). MTC2/CTC2 (writes) keep stall-only psx_gte_stall.
- MFC0: arms ld_absorb=0 / ld_which_t=rt (Beetle delayed load) — sets
  ReadFudge=rt so a load in the next slot gets no fudge.
- psx_muldiv_stall (MFLO/MFHI): now CONSUMES the pending load give-back
  while stalling to muldiv_ts_done, plus the muldiv_ts_done-1 off-by-one
  shortcut (Beetle cpu.cpp:1723-1736). No-load code is unaffected
  (read_absorb==0 -> plain advance).

Wired in both static emitters (code_generator, strict_translator) and
the dirty interp.

METHODOLOGY (Rule 15): Beetle cyc_watch must be sampled at STEADY STATE
— its warm-up window reports the no-give-back value (the source of the
prior "gte_rtps +15 EXACT" mis-record; true steady Beetle = +11).

Validated at steady state, native == Beetle on ALL 12 ruler #2 loops:
the 4 divergences closed (gte_rtps 18->14, gte_nclip 11->7,
gte_read_use 19->14, ld_div 45->49); the 8 CPU-load/alu/div/mult loops
held. Ruler #1 delta 0. Tomba 2 FMV plays (no regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 14:42:47 -07:00
parent fade5603fb
commit d5977976e5
7 changed files with 103 additions and 18 deletions
+16
View File
@@ -213,6 +213,22 @@ on a fixed region -> next.
## 5. Status / Log (update every session)
- **2026-06-27 (GTE-read + MFC0 + muldiv give-back — IMPLEMENTED + VALIDATED, ruler #2 100%):**
Closed the last steady-state divergences. KEY METHODOLOGY FINDING (Rule 15): Beetle's
cyc_watch must be sampled at STEADY STATE — its boot/warm-up window reports the
no-give-back value, which is why earlier sessions mis-recorded gte_rtps as "+15 EXACT"
(true steady = +11). Added ruler #2 probes (load_use, gte_read_use, ld_div) to Δ-gate it.
Shipped: `psx_gte_read` (MFC2/CFC2: stall to gte_ts_done AND arm ld_absorb=stall/
ld_which_t=rt give-back; MTC2/CTC2 keep stall-only `psx_gte_stall`); MFC0 arms
ld_absorb=0/ld_which_t=rt (suppresses a following load's fudge); `psx_muldiv_stall` now
CONSUMES read_absorb during the MFLO/MFHI stall + the muldiv_ts_done-1 off-by-one — all
transcribed from Beetle cpu.cpp:1332-1341/1723-1736, in both emitters + the interp.
**All 12 ruler #2 loops == Beetle at steady state** (gte_rtps 18→14, gte_nclip 11→7,
gte_read_use 19→14, ld_div 45→49 fixed; the 8 CPU-load/alu/div/mult loops held); ruler #1
delta 0; Tomba 2 FMV plays (no regression). The R3000A load-delay + GTE/muldiv interlock
is now hardware-faithful across every micro-benchmark. NEXT axis: I-cache fetch (ruler #1
84/77 cold-refill spikes). Commits on wt/tomba2-load-accuracy (unpushed).
- **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
+13 -3
View File
@@ -14,9 +14,19 @@ unchanged + matching); ruler #1 [0x80001C5C→0x80001CA4] 54 → **56** == Beetl
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 (CONFIRMED divergences — 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). **NOW Δ-GATED** by new ruler #2 probe loops, measured at
FOLLOW-UP — **DONE + VALIDATED (2026-06-27, same session).** GTE-read (MFC2/CFC2) +
MFC0 LDAbsorb give-back arming, and the muldiv-stall give-back consumption / off-by-one
(Beetle cpu.cpp:1332-1341 / 1723-1736). Shipped: `psx_gte_read` (stall + arm give-back) for
MFC2/CFC2 in both emitters + interp (MTC2/CTC2 stay `psx_gte_stall`); MFC0 arms
ld_absorb=0/ld_which_t=rt; `psx_muldiv_stall` now consumes read_absorb during the stall +
the muldiv_ts_done-1 off-by-one. **All 12 ruler #2 loops now == Beetle at steady state**:
the 4 divergences closed (gte_rtps 18→14, gte_nclip 11→7, gte_read_use 19→14, ld_div 45→49)
and the 8 prior matches held; ruler #1 delta 0; Tomba 2 FMV plays (jungle scene, no
regression). The original FOLLOW-UP text + the Δ-gate table below is retained for the record.
Original FOLLOW-UP note (CONFIRMED divergences — 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). **Δ-GATED** by new ruler #2 probe loops, measured at
STEADY STATE (Beetle must run long enough — its warm-up window reports the no-give-back
value, which is why the handoff mis-recorded gte_rtps as "+15 EXACT"; the true steady
Beetle value is +11). Steady-state native(no give-back) vs Beetle(give-back):
+17 -4
View File
@@ -1074,7 +1074,14 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
uint32_t rt = get_rt(instr);
uint32_t rd = get_rd(instr);
if (cop_op == 0x00) { // MFC0 - move from COP0
code = fmt::format("{} = cpu->cop0[{}]; /* mfc0 */", reg_name(rt), rd);
// MFC0 is a delayed load (Beetle: LDAbsorb=0, LDWhich=rt) — no
// give-back cycles, but it sets ReadFudge=rt so a load in the next
// slot gets no fudge. §1+DO_LDS ran in the block's psx_cyc_step.
code = fmt::format(
"{} = cpu->cop0[{}];"
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n cpu->ld_absorb = 0u; cpu->ld_which_t = {}u;\n#endif"
" /* mfc0 (delayed load) */",
reg_name(rt), rd, rt);
} else if (cop_op == 0x04) { // MTC0 - move to COP0
code = fmt::format("cpu->cop0[{}] = {}; /* mtc0 */", rd, reg_name(rt));
} else if (cop_op == 0x10 && (instr & 0x3F) == 0x10) { // RFE
@@ -1096,14 +1103,20 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr)
// before the access; cost-free unless an op is still in flight.
const char* gte_stall =
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n ";
// MFC2/CFC2 (GPR-dest reads): stall to the GTE deadline AND hand the
// stall amount to the next instruction(s) as a load-delay give-back
// (Beetle MFC2/CFC2: LDAbsorb=gte_ts_done-ts, LDWhich=rt). §1+DO_LDS
// ran in the block's psx_cyc_step (COP2 is non-load).
const std::string gte_read = fmt::format(
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt);
if (cop_op == 0x00) { // MFC2 - move from COP2 data
if ((rd >= 8 && rd <= 11) || rd == 15 || rd == 28 || rd == 29 || rd == 31) {
code = gte_stall + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd);
code = gte_read + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd);
} else {
code = gte_stall + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd);
code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd);
}
} else if (cop_op == 0x02) { // CFC2 - move from COP2 control
code = gte_stall + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd);
code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd);
} else if (cop_op == 0x04) { // MTC2 - move to COP2 data
if (rd == 7 || (rd >= 8 && rd <= 11) || rd == 14 || rd == 15 || rd == 28 || rd == 30) {
code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt));
+14 -5
View File
@@ -1225,8 +1225,11 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
if (rs == 0x00) { // MFC0 rt, rd -- gpr[rt] = cop0[rd]
r.supported = true;
r.c_code = emit_gpr_write(rt,
fmt::format("cpu->cop0[{}]", static_cast<int>(rd)));
// MFC0 is a delayed load (Beetle: LDAbsorb=0, LDWhich=rt): no give-back
// cycles, but sets ReadFudge=rt so a load in the next slot gets no fudge.
r.c_code = emit_gpr_write(rt, fmt::format("cpu->cop0[{}]", static_cast<int>(rd)))
+ fmt::format("\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n cpu->ld_absorb = 0u; cpu->ld_which_t = {}u;\n#endif",
static_cast<int>(rt));
r.comment = fmt::format("mfc0 {}, cop0[{}]", gpr_name(rt), rd);
return r;
}
@@ -1255,16 +1258,22 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
// completion deadline (gte_execute arms it). Cost-free unless in flight.
const std::string gte_stall =
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n ";
// MFC2/CFC2 (GPR-dest reads): stall to the GTE deadline AND hand the stall
// amount to the next instruction(s) as a load-delay give-back (Beetle
// MFC2/CFC2: LDAbsorb=gte_ts_done-ts, LDWhich=rt). §1+DO_LDS ran in the
// function's per-instruction psx_cyc_step (COP2 is non-load).
const std::string gte_read = fmt::format(
"\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", static_cast<int>(rt));
if (cop_op == 0x00) { // MFC2 — move from COP2 data register
r.supported = true;
// Computed/sign-extended data registers must route through the
// helper instead of reading the backing array directly.
if ((rd >= 8 && rd <= 11) || rd == 15 || rd == 28 || rd == 29 || rd == 31) {
r.c_code = gte_stall + emit_gpr_write(rt,
r.c_code = gte_read + emit_gpr_write(rt,
fmt::format("gte_read_data(cpu, {})", static_cast<int>(rd)));
} else {
r.c_code = gte_stall + emit_gpr_write(rt,
r.c_code = gte_read + emit_gpr_write(rt,
fmt::format("cpu->gte_data[{}]", static_cast<int>(rd)));
}
r.comment = fmt::format("mfc2 {}, gte_data[{}]", gpr_name(rt), rd);
@@ -1272,7 +1281,7 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction&
}
if (cop_op == 0x02) { // CFC2 — move from COP2 control register
r.supported = true;
r.c_code = gte_stall + emit_gpr_write(rt,
r.c_code = gte_read + emit_gpr_write(rt,
fmt::format("cpu->gte_ctrl[{}]", static_cast<int>(rd)));
r.comment = fmt::format("cfc2 {}, gte_ctrl[{}]", gpr_name(rt), rd);
return r;
+3
View File
@@ -113,6 +113,9 @@ extern uint32_t psx_mult_latency_u(uint32_t rs); /* MULTU (unsigned) */
* per-instruction cycle charging. */
extern void psx_gte_set(CPUState* cpu, uint32_t latency);
extern void psx_gte_stall(CPUState* cpu);
/* MFC2/CFC2 GTE register read: stall to the deadline AND arm the load-delay
* give-back (ld_absorb=gte_ts_done-now, ld_which_t=rt) — Beetle MFC2/CFC2. */
extern void psx_gte_read(CPUState* cpu, uint32_t rt);
extern uint32_t psx_gte_cmd_latency(uint32_t cmd); /* cost-1 for the 6-bit op */
extern void psx_unaligned_access(CPUState* cpu, uint32_t addr, uint32_t pc);
+9 -5
View File
@@ -1321,7 +1321,11 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
return 0;
case 0x10: { /* COP0 */
uint32_t cop_op = rs;
if (cop_op == 0x00) { /* MFC0 */
if (cop_op == 0x00) { /* MFC0 — delayed load (Beetle: LDAbsorb=0, LDWhich=rt) */
#ifdef PSX_ENABLE_BLOCK_CYCLES
cpu->ld_absorb = 0u;
cpu->ld_which_t = (uint8_t)rt;
#endif
cpu->gpr[rt] = cpu->cop0[rd];
cpu->gpr[0] = 0;
return 0;
@@ -1341,17 +1345,17 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) {
uint32_t cop_op = rs;
/* Faithful GTE: any COP2 register access stalls to the pending command
* completion deadline (gte_execute armed it via psx_gte_set). */
if (cop_op == 0x00) { /* MFC2 */
if (cop_op == 0x00) { /* MFC2 — read: stall + give-back (Beetle) */
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_gte_stall(cpu);
psx_gte_read(cpu, rt);
#endif
cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd);
cpu->gpr[0] = 0;
return 0;
}
if (cop_op == 0x02) { /* CFC2 */
if (cop_op == 0x02) { /* CFC2 — read: stall + give-back (Beetle) */
#ifdef PSX_ENABLE_BLOCK_CYCLES
psx_gte_stall(cpu);
psx_gte_read(cpu, rt);
#endif
cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd);
cpu->gpr[0] = 0;
+31 -1
View File
@@ -108,11 +108,41 @@ void psx_muldiv_set(CPUState* cpu, uint32_t latency) {
}
void psx_muldiv_stall(CPUState* cpu) {
/* MFLO/MFHI stall to the mult/div completion deadline (Beetle cpu.cpp:1723-1736).
* While stalling it CONSUMES a pending load-delay give-back (read_absorb) — each
* stalled cycle decrements read_absorb[read_absorb_which] — so cycles that would
* have been "free" for following instructions are spent here instead. Plus the
* off-by-one shortcut: a deadline exactly one cycle out just retracts (no stall).
* (No-load code has read_absorb==0, so this reduces to a plain advance.) */
if (cpu->muldiv_ts_done > psx_cycle_count) {
psx_advance_cycles((uint32_t)(cpu->muldiv_ts_done - psx_cycle_count));
if (cpu->muldiv_ts_done == psx_cycle_count + 1u) {
cpu->muldiv_ts_done--; /* off-by-one: retract the deadline, no advance */
return;
}
uint32_t stall = (uint32_t)(cpu->muldiv_ts_done - psx_cycle_count);
uint8_t w = cpu->read_absorb_which; /* fixed during the stall */
uint32_t give = cpu->read_absorb[w];
cpu->read_absorb[w] = (uint8_t)(give > stall ? give - stall : 0u); /* consume */
psx_advance_cycles(stall);
}
}
/* MFC2/CFC2 (GTE register read → GPR): stall to the GTE command completion deadline
* AND hand the stall amount to the next instruction(s) as a load-delay give-back
* (Beetle cpu.cpp:1332-1341: LDAbsorb = gte_ts_done - timestamp, LDWhich = rt). The
* §1+DO_LDS that bracket this ran in the instruction's psx_cyc_step (COP2 is non-load).
* MTC2/CTC2 (writes) use psx_gte_stall (stall only, no give-back). */
void psx_gte_read(CPUState* cpu, uint32_t rt) {
if (cpu->gte_ts_done > psx_cycle_count) {
uint32_t stall = (uint32_t)(cpu->gte_ts_done - psx_cycle_count);
cpu->ld_absorb = stall;
psx_advance_cycles(stall);
} else {
cpu->ld_absorb = 0u;
}
cpu->ld_which_t = (uint8_t)rt;
}
/* ---- GTE (COP2) per-command completion-stall timing ----
*
* Faithful R3000A/GTE model (Beetle cpu.cpp:1410-1412 + gte.cpp GTE_Instruction