From ec1fd76b56e28152490f2c028689cb6ed6770662 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 27 Jun 2026 12:41:22 -0700 Subject: [PATCH] timing: faithful GTE (COP2) per-command completion-stall Models the GTE command latency + stall-on-COP2-access, the next Stage-2 cycle component. Mirrors the mult/div completion-stall mechanism: - cpu->gte_ts_done (new CPUState field, appended): a GTE command sets it to psx_cycle_count + (cost-1); back-to-back commands serialize (psx_gte_set stalls to the prior deadline first). Any COP2 register access (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) calls psx_gte_stall, advancing guest cycles to the deadline if the op is still in flight. - Cost table (psx_cycles.c, psx_gte_cmd_latency): per-op cost-1, transcribed + verified from beetle-psx gte.cpp op returns (RTPS15 NCLIP8 MVMVA8 NCDS19 NCDT44 RTPT23 NCCT39 ... AVSZ4=5 not 6). cost-1 because the COP2 instruction's own +1 base is charged separately (Beetle GTE_Instruction returns ret-1). - Set armed in the shared gte_execute() (one site for both backends); stall emitted before every COP2 reg access in both emitters (code_generator, strict_translator) and the dirty interp. The compiled "+1 at top" vs interp "base after exec_one" offset cancels (same as muldiv). Validated EXACT on ruler #2 (new gte_rtps + gte_nclip loops): native +15/+8 == Beetle +15/+8. All other components unchanged (no regression); Tomba2 boots to FMV. Requires regen (emitters changed); runtime+BIOS+game regenerated. Co-Authored-By: Claude Opus 4.8 --- recompiler/src/code_generator.cpp | 33 ++++++++++----- recompiler/src/strict_translator.cpp | 33 ++++++++++----- runtime/include/cpu_state.h | 19 +++++++++ runtime/src/dirty_ram_interp.c | 20 ++++++++++ runtime/src/gte.cpp | 8 ++++ runtime/src/psx_cycles.c | 60 ++++++++++++++++++++++++++++ tools/cycle_testrom/README.md | 10 +++++ tools/cycle_testrom/gen_testrom.py | 8 ++++ 8 files changed, 169 insertions(+), 22 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 6546d34f..389182be 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1100,22 +1100,27 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) uint32_t cop_op = (instr >> 21) & 0x1F; uint32_t rt = get_rt(instr); uint32_t rd = get_rd(instr); + // Faithful GTE: any COP2 register access stalls to the pending + // command completion deadline (gte_execute arms it). Emitted + // 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 "; if (cop_op == 0x00) { // MFC2 - move from COP2 data if ((rd >= 8 && rd <= 11) || rd == 15 || rd == 28 || rd == 29 || rd == 31) { - code = fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); + code = gte_stall + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); } else { - code = fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); + code = gte_stall + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); } } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - code = fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); + code = gte_stall + 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 = fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); + code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); } else { - code = fmt::format("cpu->gte_data[{}] = {}; /* mtc2 */", rd, reg_name(rt)); + code = gte_stall + fmt::format("cpu->gte_data[{}] = {}; /* mtc2 */", rd, reg_name(rt)); } } else if (cop_op == 0x06) { // CTC2 - move to COP2 control - code = fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); + code = gte_stall + fmt::format("cpu->gte_ctrl[{}] = {}; /* ctc2 */", rd, reg_name(rt)); } else if ((cop_op & 0x10) != 0) { // GTE command (bit 25 set) uint32_t gte_cmd = instr & 0x1FFFFFF; // Route ALL GTE commands through gte_execute() for correct behavior @@ -1145,18 +1150,21 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); int16_t offset = get_imm16(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 "; 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 = fmt::format("gte_write_data(cpu, {}, cpu->read_word({})); /* lwc2 gte[{}] */", + code = gte_stall + fmt::format("gte_write_data(cpu, {}, cpu->read_word({})); /* lwc2 gte[{}] */", rt, addr, rt); } else if (offset == 0) { - code = fmt::format("cpu->gte_data[{}] = cpu->read_word({}); /* lwc2 gte[{}], ({}) */", + code = gte_stall + fmt::format("cpu->gte_data[{}] = cpu->read_word({}); /* lwc2 gte[{}], ({}) */", rt, reg_name(rs), rt, reg_name(rs)); } else { - code = fmt::format("cpu->gte_data[{}] = cpu->read_word({} + {}); /* lwc2 gte[{}], {}({}) */", + code = gte_stall + fmt::format("cpu->gte_data[{}] = cpu->read_word({} + {}); /* lwc2 gte[{}], {}({}) */", rt, reg_name(rs), offset, rt, offset, reg_name(rs)); } } @@ -1166,14 +1174,17 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); int16_t offset = get_imm16(instr); + // Faithful GTE: COP2 reg read stalls to the command deadline. + const char* gte_stall = + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; std::string value = ((rt >= 8 && rt <= 11) || rt == 15 || rt == 28 || rt == 29 || rt == 31) ? fmt::format("gte_read_data(cpu, {})", rt) : fmt::format("cpu->gte_data[{}]", rt); if (offset == 0) { - code = fmt::format("cpu->write_word({}, {}); /* swc2 gte[{}], ({}) */", + code = gte_stall + fmt::format("cpu->write_word({}, {}); /* swc2 gte[{}], ({}) */", reg_name(rs), value, rt, reg_name(rs)); } else { - code = fmt::format("cpu->write_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", + code = gte_stall + fmt::format("cpu->write_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", reg_name(rs), offset, value, rt, offset, reg_name(rs)); } } diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index 9b0387eb..75268ed2 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -1242,15 +1242,20 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& const uint8_t rt = (d.raw >> 16) & 0x1F; const uint8_t rd = (d.raw >> 11) & 0x1F; + // Faithful GTE: any COP2 register access stalls to the pending command + // 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 "; + 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 = emit_gpr_write(rt, + r.c_code = gte_stall + emit_gpr_write(rt, fmt::format("gte_read_data(cpu, {})", static_cast(rd))); } else { - r.c_code = emit_gpr_write(rt, + r.c_code = gte_stall + emit_gpr_write(rt, fmt::format("cpu->gte_data[{}]", static_cast(rd))); } r.comment = fmt::format("mfc2 {}, gte_data[{}]", gpr_name(rt), rd); @@ -1258,7 +1263,7 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& } if (cop_op == 0x02) { // CFC2 — move from COP2 control register r.supported = true; - r.c_code = emit_gpr_write(rt, + r.c_code = gte_stall + 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; @@ -1268,11 +1273,11 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& // Route registers with side effects or canonical masking through // gte_write_data(): OTZ/IR/SXY2/SXYP/IRGB/LZCS. if (rd == 7 || (rd >= 8 && rd <= 11) || rd == 14 || rd == 15 || rd == 28 || rd == 30) { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "gte_write_data(cpu, {}, cpu->gpr[{}]);", static_cast(rd), static_cast(rt)); } else { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "cpu->gte_data[{}] = cpu->gpr[{}];", static_cast(rd), static_cast(rt)); } @@ -1281,7 +1286,7 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& } if (cop_op == 0x06) { // CTC2 — move to COP2 control register r.supported = true; - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "cpu->gte_ctrl[{}] = cpu->gpr[{}];", static_cast(rd), static_cast(rt)); r.comment = fmt::format("ctc2 {}, gte_ctrl[{}]", gpr_name(rt), rd); @@ -1307,20 +1312,23 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& const uint8_t rt = (d.raw >> 16) & 0x1F; const int16_t offset = static_cast(d.raw & 0xFFFF); r.supported = true; + // 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 "; 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(rs)) : fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast(rs), static_cast(offset)); - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "gte_write_data(cpu, {}, cpu->read_word({}));", static_cast(rt), addr); } else if (offset == 0) { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "cpu->gte_data[{}] = cpu->read_word(cpu->gpr[{}]);", static_cast(rt), static_cast(rs)); } else { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "cpu->gte_data[{}] = cpu->read_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})));", static_cast(rt), static_cast(rs), static_cast(offset)); } @@ -1335,16 +1343,19 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& const uint8_t rt = (d.raw >> 16) & 0x1F; const int16_t offset = static_cast(d.raw & 0xFFFF); r.supported = true; + // Faithful GTE: COP2 reg read stalls to the command deadline. + const std::string gte_stall = + "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_stall(cpu);\n#endif\n "; std::string value = ((rt >= 8 && rt <= 11) || rt == 15 || rt == 28 || rt == 29 || rt == 31) ? fmt::format("gte_read_data(cpu, {})", static_cast(rt)) : fmt::format("cpu->gte_data[{}]", static_cast(rt)); if (offset == 0) { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "g_debug_last_store_pc = 0x{:08X}u; " "cpu->write_word(cpu->gpr[{}], {});", d.address, static_cast(rs), value); } else { - r.c_code = fmt::format( + r.c_code = gte_stall + fmt::format( "g_debug_last_store_pc = 0x{:08X}u; " "cpu->write_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})), {});", d.address, static_cast(rs), static_cast(offset), value); diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 60c98fb7..95ecf494 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -61,6 +61,15 @@ typedef struct CPUState { * cycles) until this deadline — faithful R3000A behavior (Beetle * muldiv_ts_done). Appended at END so prior field offsets are unchanged. */ uint64_t muldiv_ts_done; + + /* GTE (COP2) command completion deadline (absolute guest cycle). A GTE + * command sets this to psx_cycle_count()+(cost-1) (cost from gte.cpp per-op + * returns; -1 because the COP2 instruction's own +1 base is charged + * separately). Back-to-back commands serialize (set stalls to the prior + * deadline first); any COP2 register access (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) + * stalls until it — faithful R3000A behavior (Beetle gte_ts_done). + * Appended at END so prior field offsets are unchanged. */ + uint64_t gte_ts_done; } CPUState; /* Trap trampolines — defined in runtime/src/traps.c */ @@ -82,6 +91,16 @@ extern void psx_muldiv_stall(CPUState* cpu); extern uint32_t psx_mult_latency_s(uint32_t rs); /* MULT (signed) */ extern uint32_t psx_mult_latency_u(uint32_t rs); /* MULTU (unsigned) */ +/* GTE (COP2) per-command completion-stall timing (psx_cycles.c). gte_execute() + * calls psx_gte_set with the command's added latency (cost-1, from + * psx_gte_cmd_latency); it serializes back-to-back ops by stalling to the prior + * deadline first. Every COP2 register access calls psx_gte_stall, which advances + * guest cycles to the deadline if the op is still in flight. Faithful only with + * per-instruction cycle charging. */ +extern void psx_gte_set(CPUState* cpu, uint32_t latency); +extern void psx_gte_stall(CPUState* cpu); +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); extern void psx_break(CPUState* cpu, uint32_t code, uint32_t pc); /* Fail-closed native entry guard: a function's CPS entry-switch calls this when diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index d3fc7c48..372728ed 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1335,21 +1335,35 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { } case 0x12: { /* COP2 / GTE */ 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 */ +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_stall(cpu); +#endif cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd); cpu->gpr[0] = 0; return 0; } if (cop_op == 0x02) { /* CFC2 */ +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_stall(cpu); +#endif cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd); cpu->gpr[0] = 0; return 0; } if (cop_op == 0x04) { /* MTC2 */ +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_stall(cpu); +#endif gte_write_data(cpu, (uint8_t)rd, cpu->gpr[rt]); return 0; } if (cop_op == 0x06) { /* CTC2 */ +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_stall(cpu); +#endif gte_write_ctrl(cpu, (uint8_t)rd, cpu->gpr[rt]); return 0; } @@ -1436,11 +1450,17 @@ static int exec_one(CPUState *cpu, uint32_t pc, uint32_t *next_pc_out) { } case 0x32: { /* LWC2 */ 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)); return 0; } case 0x3A: { /* SWC2 */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; +#ifdef PSX_ENABLE_BLOCK_CYCLES + psx_gte_stall(cpu); /* COP2 reg read stalls to GTE completion */ +#endif cpu->write_word(addr, gte_read_data(cpu, (uint8_t)rt)); return 0; } diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 5bd354ef..3b0b7039 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -883,6 +883,14 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { for (int i = 0; i < 32; i++) cpu->gte_data[i] = gte_mfc2(>e, i); for (int i = 0; i < 32; i++) cpu->gte_ctrl[i] = gte_cfc2(>e, i); + +#ifdef PSX_ENABLE_BLOCK_CYCLES + /* Faithful GTE command completion-stall: arm the per-command deadline + * (serializing back-to-back ops). Any later COP2 register access stalls to + * it. Single shared site for BOTH backends (compiled + dirty interp both + * route GTE commands through gte_execute). */ + psx_gte_set(cpu, psx_gte_cmd_latency(cmd)); +#endif } /* C-callable wrappers for GTE register transfers */ diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index bfadbcaf..5f3c7c54 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -112,3 +112,63 @@ void psx_muldiv_stall(CPUState* cpu) { psx_advance_cycles((uint32_t)(cpu->muldiv_ts_done - psx_cycle_count)); } } + +/* ---- GTE (COP2) per-command completion-stall timing ---- + * + * Faithful R3000A/GTE model (Beetle cpu.cpp:1410-1412 + gte.cpp GTE_Instruction + * return(ret-1)). Each GTE command takes `cost` cycles (gte.cpp per-op returns, + * verified from source); the COP2 instruction's own +1 base is charged + * separately by per-instruction charging, so the *added* deadline latency is + * cost-1. A later COP2 register access (MFC2/CFC2/MTC2/CTC2/LWC2/SWC2) stalls + * until the deadline. Back-to-back commands serialize: psx_gte_set stalls to the + * prior deadline before arming the next (Beetle stalls timestamp to gte_ts_done + * at the command site before computing the new gte_ts_done). + * + * Cost table = (cost-1), indexed by the 6-bit GTE command (instr & 0x3F). + * cost values transcribed + verified from beetle-psx/mednafen/psx/gte.cpp op + * returns: RTPS15 RTPT23 MVMVA8 SQR5 OP6 AVSZ3/4=5 NCLIP8 NCDS19 NCDT44 NCCS17 + * NCCT39 NCS14 NCT30 CC11 CDP13 DPCS8 DPCT17 DCPL8 INTPL8 GPF5 GPL5. Unknown/ + * undefined commands = 1 cycle (Beetle default ret=1) -> 0 added. */ +static const uint8_t PSX_GTE_LAT_M1[64] = { + [0x00] = 14, [0x01] = 14, /* RTPS */ + [0x06] = 7, /* NCLIP */ + [0x0C] = 5, /* OP */ + [0x10] = 7, /* DPCS */ + [0x11] = 7, /* INTPL */ + [0x12] = 7, /* MVMVA */ + [0x13] = 18, /* NCDS */ + [0x14] = 12, /* CDP */ + [0x16] = 43, /* NCDT */ + [0x1A] = 7, /* DCPL (alt of 0x29) */ + [0x1B] = 16, /* NCCS */ + [0x1C] = 10, /* CC */ + [0x1E] = 13, /* NCS */ + [0x20] = 29, /* NCT */ + [0x28] = 4, /* SQR */ + [0x29] = 7, /* DCPL */ + [0x2A] = 16, /* DPCT */ + [0x2D] = 4, /* AVSZ3 */ + [0x2E] = 4, /* AVSZ4 */ + [0x30] = 22, /* RTPT */ + [0x3D] = 4, /* GPF */ + [0x3E] = 4, /* GPL */ + [0x3F] = 38, /* NCCT */ +}; + +uint32_t psx_gte_cmd_latency(uint32_t cmd) { + return (uint32_t)PSX_GTE_LAT_M1[cmd & 0x3Fu]; +} + +void psx_gte_set(CPUState* cpu, uint32_t latency) { + /* Back-to-back GTE ops serialize: finish the prior op first. */ + if (cpu->gte_ts_done > psx_cycle_count) { + psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); + } + cpu->gte_ts_done = psx_cycle_count + (uint64_t)latency; +} + +void psx_gte_stall(CPUState* cpu) { + if (cpu->gte_ts_done > psx_cycle_count) { + psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count)); + } +} diff --git a/tools/cycle_testrom/README.md b/tools/cycle_testrom/README.md index 02170814..0f462e25 100644 --- a/tools/cycle_testrom/README.md +++ b/tools/cycle_testrom/README.md @@ -25,6 +25,8 @@ Loops (each adds ONLY its op(s) over the shared loop overhead): - `div` — + divu, mflo (div latency + stall-on-read, worst case) - `div_spaced` — + divu, 2 filler addu, mflo (stall partly absorbed) - `mult` — + multu, mflo (mult latency) +- `gte_rtps` — + RTPS cmd, mfc2 (GTE per-command stall; cost 15) +- `gte_nclip` — + NCLIP cmd, mfc2 (GTE per-command stall; cost 8) **Isolation by baseline subtraction:** (component_per_iter − baseline_per_iter) = the component's cost; loop overhead AND instruction fetch (both fully @@ -91,6 +93,14 @@ Per-iteration cycle delta, and component cost (minus baseline=3): | div | 41 | +38 | divu+mflo base 2 + ~36 div stall | | div_spaced | 41 | +38 | divu+2addu+mflo — fillers ABSORBED by stall | | mult | 18 | +15 | multu+mflo base 2 + ~13 mult stall | +| gte_rtps | 18 | +15 | RTPS cmd + mfc2 stall (gte.cpp RTPS=15) | +| gte_nclip | 11 | +8 | NCLIP cmd + mfc2 stall (gte.cpp NCLIP=8) | + +**GTE per-command stall — VALIDATED EXACT 2026-06-27:** native (compiled path) +gte_rtps +15 == Beetle +15, gte_nclip +8 == Beetle +8. Modeled via +`psx_gte_set`/`psx_gte_stall` (cpu->gte_ts_done), cost table in `psx_cycles.c` +(verified from gte.cpp op returns). All other components unchanged (load2 +10 vs +Beetle +11 = the remaining ReadFudge gap). Key targets for the native cost model: **div stall ~36, mult stall ~13** (native charges 0). Load ~5/6 (native memory.c flat +6 — close). **`div_spaced`==`div` diff --git a/tools/cycle_testrom/gen_testrom.py b/tools/cycle_testrom/gen_testrom.py index 84d641fe..83f4c197 100644 --- a/tools/cycle_testrom/gen_testrom.py +++ b/tools/cycle_testrom/gen_testrom.py @@ -64,6 +64,8 @@ def mflo(rd): return R(0x12, 0, 0, rd) def mfhi(rd): return R(0x10, 0, 0, rd) def lw(rt, off, base): return I(0x23, base, rt, off) def sw(rt, off, base): return I(0x2B, base, rt, off) +def cop2cmd(cmd): return (0x12 << 26) | (1 << 25) | (cmd & 0x1FFFFFF) # GTE command +def mfc2(rt, rd): return (0x12 << 26) | (0x00 << 21) | (_r(rt) << 16) | ((rd & 0x1F) << 11) # MFC2 rt,gte[rd] def bne(rs, rt, off): return I(0x05, rs, rt, off) # off in words, signed def beq(rs, rt, off): return I(0x04, rs, rt, off) def jr(rs): return R(0x08, rs) @@ -160,6 +162,12 @@ def build(): mflo("$t4")]) # mult: multu + mflo (isolates mult latency) inner_loop("mult", [multu("$t2", "$t3"), mflo("$t4")]) + # gte_rtps: GTE RTPS command (0x01) + mfc2 result read. Isolates the GTE + # per-command completion-stall: the COP2 read stalls to the command deadline. + # Cost is operand-independent (gte.cpp RTPS returns 15), so delta == 15. + inner_loop("gte_rtps", [cop2cmd(0x01), mfc2("$t4", 14)]) + # gte_nclip: NCLIP command (0x06, cost 8) + mfc2 MAC0 (reg 24) read. + inner_loop("gte_nclip", [cop2cmd(0x06), mfc2("$t4", 24)]) # loop outer forever o = a.labels["outer"]