From d5977976e576e2b65933a197a16bdc7142d6325a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 27 Jun 2026 14:42:47 -0700 Subject: [PATCH] timing: GTE-read + MFC0 + muldiv-stall load-delay give-back (ruler#2 100%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FAITHFUL_TIMING_PLAN.md | 16 ++++++++++++++ accuracy/load_readfudge_ldabsorb.md | 16 +++++++++++--- recompiler/src/code_generator.cpp | 21 ++++++++++++++---- recompiler/src/strict_translator.cpp | 19 ++++++++++++----- runtime/include/cpu_state.h | 3 +++ runtime/src/dirty_ram_interp.c | 14 +++++++----- runtime/src/psx_cycles.c | 32 +++++++++++++++++++++++++++- 7 files changed, 103 insertions(+), 18 deletions(-) diff --git a/FAITHFUL_TIMING_PLAN.md b/FAITHFUL_TIMING_PLAN.md index 86d0541d..d6289095 100644 --- a/FAITHFUL_TIMING_PLAN.md +++ b/FAITHFUL_TIMING_PLAN.md @@ -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 diff --git a/accuracy/load_readfudge_ldabsorb.md b/accuracy/load_readfudge_ldabsorb.md index fbc760e4..28dd59c7 100644 --- a/accuracy/load_readfudge_ldabsorb.md +++ b/accuracy/load_readfudge_ldabsorb.md @@ -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): diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b76dcbf9..4b0c5cf9 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -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)); diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index 05732b1a..e3ae59d9 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -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(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(rd))) + + fmt::format("\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n cpu->ld_absorb = 0u; cpu->ld_which_t = {}u;\n#endif", + static_cast(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(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(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(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(rd))); r.comment = fmt::format("cfc2 {}, gte_ctrl[{}]", gpr_name(rt), rd); return r; diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index aa6fd01c..ff5b3cd0 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -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); diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index b073e6df..eef3670f 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -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; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 5f3c7c54..1da1dff6 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -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