From d3c97600ba4429beff9d04271fa67c9ff2588f88 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Tue, 4 Aug 2026 10:06:53 -1000 Subject: [PATCH] Lockstep: match the native block's loop-exit point, not the first arrival The shadow interpreter stopped at the first arrival at a loop header. A generated chunk leaves an inlined loop at its header only once the block's charge crosses DOLRECOMP_C_LOOP_CYCLE_BUDGET (emitter.c:324, default 256), so its stopping iteration is timing-determined and cannot be inferred from the code. The two sides were therefore compared at the same PC but in different iterations of the same loop, producing GPR divergences that had nothing to do with codegen. Example: entry=0x8000341C reported r3 N=0x3299/I=0x32a2, r4 N=0x803640dc/I=0x80363fbc. r3 decrements, r4 advances 32 bytes per iteration, and 0x120 = 9 * 32 -- both agree the native side ran 9 more iterations. A per-step trace showed the shadow burning 172 cycles against charge=263. interp_cycles is already accumulated and native_charge is already a parameter, so the shadow can simply keep going until it has done as much work. Measured on Mario Kart: Double Dash, 150 s from a race savestate, MODERNGEKKO_NO_FALLBACK_JIT=1: 271 -> 107 divergences, with every loop-header entry gone. The remaining 107 are each at a distinct entry PC, none diverge on pc, and none fail to reach end_pc; 2 touch MMIO (0xCC003000), which a replay cannot reproduce by construction. --- .../StaticRecomp/StaticRecompLockstep_Check.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompLockstep_Check.cpp b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompLockstep_Check.cpp index 1d9427cfd7..4191012e9e 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompLockstep_Check.cpp +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompLockstep_Check.cpp @@ -184,7 +184,16 @@ void StaticRecompLockstepVerifier::LockstepCheck(u32 entry_pc, u32 end_pc, const // must execute the inlined iteration before stopping. An arrival by any // branch (including a call to an address which also happens to be a loop // header) is already the native dispatch boundary. - if (!end_is_loop_header || before + 4u != end_pc) + // ...and on a loop header, not until the shadow has done as much work as + // the native block did. A generated chunk leaves an inlined loop at its + // header once the block's charge crosses DOLRECOMP_C_LOOP_CYCLE_BUDGET + // (emitter.c:324), so the iteration it stops on is timing-determined and + // cannot be inferred from the code. Stopping at the first branch arrival + // compares two different iterations of the same loop at the same PC, + // which is the long-standing GPR divergence on both arm64 and x86-64. + if (!end_is_loop_header) + break; + if (interp_cycles >= native_charge) break; } if (ppc.Exceptions != 0)