arm64 mVU: mVU_XGKICK_SYNC must fully flush the regalloc unconditionally

Root cause of the Crash Twinsanity VIF1<->VU1 VEW-deadlock hang.

mVU_XGKICK_SYNC opened with flushCallerSavedRegisters() (x9-x15 only)
and relied on the mVUbackupRegs (= regAlloc->flushAll()) emitted between
the runtime `xgkickenable` / `cyclecount >= 2` branches and the
_vuXGKICKTransfermVU call to spill everything else. But the allocator
marks registers clean at COMPILE time, unconditionally — so a dirty
CALLEE-SAVED VI allocation (w26) got its only writeback store emitted on
a conditionally-executed path. At runtime, whenever no kick was pending
(xgkickenable==0 — e.g. always, in programs with no XGKICK op), the
spill was skipped: the value died in the callee-saved register at
dispatcher exit, and the E-bit end flush wrote nothing because the reg
was already "clean".

x86 shares the emit structure but is immune: its mVU GPR pool is all
caller-saved, so the top-of-function flush covers every dirty register
and the conditional backup never has regalloc state left to spill. Our
callee-saved-bearing pool turned the latent structure into a real bug.

Twinsanity (GameDB forces XGKickHack=true) lost exactly two writes per
frame in the pc0x0 display-list trampoline: the JALR link (vi15=0x0a)
and the terminal handler's vi12=0 (double-buffer parity) — both landing
in w26 with sync sites before their next flush. The walk then wedged,
VIF1's VEW never cleared, and the EE bc0f-spun at 0x1820a4. Invisible to
every offline oracle because the replay harness pins XgKickHack=false
(different compiled shape); reproduced offline via PCSX2_VU_XGKICKHACK=1
on the fresh-boot capture (vi12/vi15 signature exact, from frame 0).

Fix: flushAll() before any conditional emission, so every store the
allocator accounts for executes on every runtime path.

TDD: Vu1Xgkick.XgKickHackSyncMustNotLoseCalleeSavedViWrites red on
unfixed code (vi3 JIT=0 vs INTERP=9), green after. recompiler_tests
1123/1123. twin-fresh2 corpus: hack-ON replay now bit-identical to
hack-OFF reference on 768/769 caps (outlier = the budget-truncated
final capture of the already-wedged state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-09 18:22:30 -07:00
co-authored by Claude Fable 5
parent 3a423cdb98
commit 9ee69fc238
2 changed files with 56 additions and 1 deletions
+11 -1
View File
@@ -1060,7 +1060,17 @@ static __fi void mVU_XGKICK_DELAY(mV)
// JIT emitter: emit code for cycle-counted XGKICK sync
static __fi void mVU_XGKICK_SYNC(mV, bool flush)
{
mVU.regAlloc->flushCallerSavedRegisters();
// Full flush, NOT flushCallerSavedRegisters(): every store emitted here
// must sit BEFORE the runtime xgkickenable / cyclecount branches below,
// because the allocator marks the regs clean at compile time
// unconditionally. Our GPR pool contains CALLEE-SAVED registers (unlike
// x86's all-caller-saved pool), and a dirty callee-saved VI would
// otherwise only be spilled by the mVUbackupRegs inside the conditional
// path — skipped at runtime whenever no kick is pending, silently losing
// the write (Crash Twinsanity: pc0x0's JALR link vi15 and the terminal
// handler's vi12=0 died in w26 => VIF1 VEW deadlock; pinned by
// Vu1Xgkick.XgKickHackSyncMustNotLoseCalleeSavedViWrites).
mVU.regAlloc->flushAll();
// Test if xgkickenable is set
a64::Label skipxgkick;
@@ -24,6 +24,7 @@
#include "harness/VuTestHarness.h"
#include "Config.h"
#include "VU.h"
#include <gtest/gtest.h>
@@ -46,6 +47,50 @@ void WriteEopOnlyTagToVu1(VuTestHarness& h, u32 addr)
} // namespace
// Crash Twinsanity wedge root cause (2026-07-09): with the XgKickHack gamefix
// ON (GameDB forces it for Twinsanity), every mVU_XGKICK_SYNC site must not
// lose dirty VI register state. The sync's top-of-function
// flushCallerSavedRegisters() only covers x9-x15; a VI allocated into a
// CALLEE-SAVED pool register (w26) was only spilled by the mVUbackupRegs
// emitted BETWEEN the runtime `xgkickenable` / `cyclecount >= 2` branches and
// the C call — so at runtime the spill is skipped whenever no kick is pending
// (xgkickenable==0), while the allocator's compile-time state says the reg is
// clean. The value dies in the callee-saved reg at dispatcher exit; the E-bit
// end flush writes nothing. Live signature: pc0x0's JALR link (vi15=0x0a) and
// the terminal handler's vi12=0 lost => VIF1 VEW deadlock. x86 shares the
// emit structure but is immune (its mVU GPR pool is all caller-saved).
//
// Program shape: three distinct VI writes so the third allocation lands in
// the callee-saved pool reg, then an ISW (isMemWrite => kickcycles flush =>
// SYNC site emitted before it) while that VI is dirty, then E-bit. No XGKICK
// op at all — xgkickenable stays 0, so the buggy conditional spill is always
// skipped at runtime.
TEST(Vu1Xgkick, XgKickHackSyncMustNotLoseCalleeSavedViWrites)
{
VuTestHarness h(1);
const bool saved_hack = EmuConfig.Gamefixes.XgKickHack;
EmuConfig.Gamefixes.XgKickHack = true;
h.LoadProgram({
LowerOnly(VIADDIU_L(vi::vi1, vi::vi0, 5)), // vi1 = 5 (caller-saved alloc)
LowerOnly(VIADDIU_L(vi::vi2, vi::vi0, 7)), // vi2 = 7 (caller-saved alloc)
LowerOnly(VIADDIU_L(vi::vi3, vi::vi0, 9)), // vi3 = 9 (callee-saved alloc)
LowerOnly(VISW_L(mask::x, vi::vi1, vi::vi0, 9)), // ISW => SYNC site while vi3 dirty
EBitNopPair(),
});
h.Run();
EmuConfig.Gamefixes.XgKickHack = saved_hack;
EXPECT_EQ(h.GetViJit(vi::vi1), 5u);
EXPECT_EQ(h.GetViJit(vi::vi2), 7u);
EXPECT_EQ(h.GetViJit(vi::vi3), 9u)
<< "VI write lost across an XgKickHack mVU_XGKICK_SYNC site: the "
"callee-saved spill was emitted inside the sync's runtime "
"conditionals (Crash Twinsanity vi12/vi15 wedge)";
}
TEST(Vu1Xgkick, EopOnlyTagEmitsMatchingPath1Stream)
{
VuTestHarness h(1);