Tests: pin the rec's TLB-miss divert, disabled

A TLB miss on an inline load or store leaves the arm64 EE rec at
exception level in user code. Eight cases, all disabled, because the rec
does not do this yet and the route to making it is staged: x86's
behaviour first as a floor, then the divert built back up with these
dropping their prefix one at a time.

Found from the other end. `3D Pinball Space Cadet (PS2) (3.0) (RA)`
hangs after "Parsing complete. Finalizing...", and the visible fault is
a thread id of 0xff966c22 arriving at a caller whose syscall returned 1.
That is strlen's `subu v0,v0,a0` on the correct v0, reached because the
kernel's syscall epilogue eret'ed into the middle of strlen: 131072 of
the run's next 162766 exceptions were taken with EXL already set, so
none of them updated EPC. Upstream of all of it is one swallowed miss on
a strlen(NULL). Two working hypotheses died on the way — that the
pinned-GPR cache lost v0, and that the call-ret shadow stack mispopped —
both refuted by reading the state at the moment of damage rather than by
reasoning about the emitters.

The tests are the chain in four instructions, plus the load and store
halves of both inline emitter pairs, plus the flush the divert needs.

Three separate defects fall out of the one missing poll. The block runs
past the faulting load. EPC names the instruction after it, because
cpuTlbMiss skips its `pc -= 4` for the rec while the rec's own cursor is
already one instruction ahead outside a delay slot — the delay-slot case
comes out right only because two errors cancel. And Cause is whatever
exception came last, describing a different instruction than EPC does.

Two findings shape the fix rather than the tests, so they are recorded
here.

Reaching the vector with guest state intact costs one writeback, not a
general flush: iFlushCall(FLUSH_VTLB) already precedes every inline
access and frees the caller-saved hosts, leaving only x28, the
allocator's single callee-saved host. A boot with fastmem off puts a
live dirty guest GPR there at 489 sites and nothing else anywhere.

The fastmem backpatch thunk is the part with no clean answer. It is
generated at fault time and cannot name the live guest values of the
block around it, so it cannot divert. A census of the 12411 fastmem
sites emitted during that boot says how much per-site state a precise
one would need: 9355 have nothing live and dirty, 2480 have between one
and seven GPRs, and 576 involve the NEON file.

Lesson, from a guard test that was written wrong first. Three dirty
registers before the faulting load is not enough to make the allocator
reach x28, so that test passed with the writeback deleted — it guarded
nothing. It takes sixteen live guest values at once. A test that guards
a writeback has to create the pressure that puts something in the
register the writeback exists for, and the way to find out is to delete
the code and watch.
This commit is contained in:
pstef
2026-08-18 08:00:56 -07:00
committed by Brian Degenhardt
parent 83f2510134
commit 249aefdb9b
2 changed files with 295 additions and 0 deletions
@@ -69,6 +69,7 @@ add_pcsx2_test(recompiler_tests
ee_rec_smc_manual_tests.cpp
ee_rec_smc_tests.cpp
ee_rec_timeout_loop_tests.cpp
ee_rec_tlb_divert_tests.cpp
ee_rec_traps_tests.cpp
ee_timer_count_read_race_tests.cpp
vtlb_get_guest_address_tests.cpp
@@ -0,0 +1,294 @@
// SPDX-FileCopyrightText: 2026 ARMSX2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
// A TLB miss on an inline load or store, outside a branch delay slot.
//
// Every test here is DISABLED: the arm64 EE rec does not divert to the
// exception vector, and the work to make it is staged. Force-enable with
// --gtest_also_run_disabled_tests; each one that starts passing drops its
// prefix.
//
// vtlb_Miss (vtlb.cpp) raises on arm64 and returns. cpuTlbMissR/W has latched
// Status.EXL, EPC and Cause and pointed cpuRegs.pc at the vector, but the only
// divert the rec emits is recEmitInterpTlbMissCheck, after an interpreter
// call. The inline fastmem/softmem paths have no poll, so the block runs on
// and its tail stores its own branch target over the vector PC. The delay-slot
// case is the exception: the divert rides the cpuRegs.branch bracket epilogue,
// and EeRecTraps.LoadTlbMissInDelaySlotSetsCauseBdAndBranchEpc covers it.
//
// The latched EXL is what does the damage rather than the continued execution.
// cpuException leaves EPC alone whenever EXL is already set, so from the first
// swallowed miss onward every exception keeps its predecessor's EPC, and the
// next syscall's kernel epilogue erets to an address belonging to the fault.
// DISABLED_MissLeavesExlLatchedSoTheNextSyscallLosesItsEpc is that chain in
// four instructions.
//
// Softmem, not fastmem: the test binary installs no host SIGSEGV handler, so a
// fastmem probe of the unmapped page kills the process instead of backpatching
// (same constraint as EeRecTraps.LoadTlbMissInDelaySlotSetsCauseBdAndBranchEpc
// and CallerSavedPinsSurviveVtlbSlowPath). Nothing here reaches the fastmem
// backpatch thunk, which is generated at fault time and cannot name the live
// guest values of the block around it, so it has nothing to divert with.
#include "harness/EeRecTestHarness.h"
#include "Config.h"
#include "R5900.h"
#include <gtest/gtest.h>
#include <iterator>
#include <vector>
using namespace recompiler_tests;
using namespace mips;
namespace {
constexpr u32 kUnmapped = 0x40000000; // useg, no TLB entry
constexpr u32 kCauseTlbL = 0x08; // ExcCode=2 (TLBL), << 2
constexpr u32 kCauseTlbS = 0x0C; // ExcCode=3 (TLBS), << 2
constexpr u32 kCauseSys = 0x20; // ExcCode=8 (Sys), << 2
// Restores EnableFastmem whatever the assertions do.
class SoftmemScope
{
public:
SoftmemScope()
: saved_(EmuConfig.Cpu.Recompiler.EnableFastmem)
{
EmuConfig.Cpu.Recompiler.EnableFastmem = false;
}
~SoftmemScope() { EmuConfig.Cpu.Recompiler.EnableFastmem = saved_; }
private:
bool saved_;
};
} // namespace
// The faulting load is the last instruction that executes. Everything after it
// belongs to the exception handler, which the harness stubs at the TLB-refill
// vector with `jr ra; nop` back to the parking lot.
TEST(EeRecTlbDivert, DISABLED_LoadMissDivertsToTheVector)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.LoadProgram({
LUI(reg::a0, kUnmapped >> 16), // +0x0
LW(reg::v1, 0, reg::a0), // +0x4 TLB refill miss
ADDIU(reg::v0, reg::zero, 99), // +0x8 must not execute
});
h.Run();
h.ExpectGpr64(reg::v0, 0ull); // the block must not run on past the miss
h.ExpectGpr64(reg::v1, 0ull); // the faulting load must not write rt
EXPECT_EQ(h.GetCp0Interp(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Interp(13) & 0x80000000u, 0u) << "interp CAUSE.BD clear";
EXPECT_EQ(h.GetCp0Interp(14), RecompilerTestEnvironment::kProgramPc + 4)
<< "interp EPC = the faulting load";
EXPECT_EQ(h.GetCp0Interp(8), kUnmapped);
EXPECT_EQ(h.GetCp0Jit(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Jit(13) & 0x80000000u, 0u) << "JIT CAUSE.BD clear";
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + 4)
<< "JIT EPC = the faulting load";
EXPECT_EQ(h.GetCp0Jit(8), kUnmapped);
}
// The store side of the class. vtlbSoftmemWrite's slow path and the const-paddr
// write shortcut are separate emitters from their read twins, so each needs its
// own poll — TLBS instead of TLBL, everything else identical.
TEST(EeRecTlbDivert, DISABLED_StoreMissDivertsToTheVector)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.SetGpr64(reg::a1, 0x1234);
h.LoadProgram({
LUI(reg::a0, kUnmapped >> 16), // +0x0
SW(reg::a1, 0, reg::a0), // +0x4 TLB refill miss on a store
ADDIU(reg::v0, reg::zero, 99), // +0x8 must not execute
});
h.Run();
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Interp(13) & 0xFFu, kCauseTlbS);
EXPECT_EQ(h.GetCp0Interp(14), RecompilerTestEnvironment::kProgramPc + 4);
EXPECT_EQ(h.GetCp0Jit(13) & 0xFFu, kCauseTlbS);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + 4)
<< "JIT EPC = the faulting store";
}
// The 3D Pinball chain. cpuException leaves EPC alone whenever EXL is already
// set (R5900.cpp, architectural MIPS), so a swallowed miss does not merely lose
// one instruction — it silently disarms EPC for every exception that follows.
// The SYSCALL here stands in for the game's `jal GetThreadId`, whose kernel
// epilogue then computes its return address from an EPC belonging to the
// faulting load and erets into the middle of strlen.
//
// Required: the block leaves at +0x4, so neither +0x8 nor +0xC runs, Cause
// still reads TLBL and EPC still points at the load. Today it runs on into the
// SYSCALL, which overwrites Cause with Sys and — EXL being latched — does not
// update EPC. Cause and EPC describing two different instructions is the
// corruption itself, and it is what the game's kernel epilogue then erets on.
TEST(EeRecTlbDivert, DISABLED_MissLeavesExlLatchedSoTheNextSyscallLosesItsEpc)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.LoadProgram({
LUI(reg::a0, kUnmapped >> 16), // +0x0
LW(reg::v1, 0, reg::a0), // +0x4 TLB refill miss
ADDIU(reg::v0, reg::zero, 99), // +0x8 must not execute
SYSCALL_(), // +0xC must not execute
});
h.Run();
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Interp(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Interp(14), RecompilerTestEnvironment::kProgramPc + 4);
EXPECT_NE(h.GetCp0Jit(13) & 0xFFu, kCauseSys)
<< "the SYSCALL after the swallowed miss must never have executed";
EXPECT_EQ(h.GetCp0Jit(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + 4)
<< "EPC must still name the load, not a later exception's instruction";
}
// A const base (LUI, above) resolves its page at compile time and takes the
// const-paddr MMIO shortcut — a direct BL to the unmapped handler. A base the
// block cannot fold takes the generic path instead: the inline vmap lookup in
// vtlbSoftmemRead / vtlbSoftmemWrite and its slow-path call to vtlb_memRead /
// vtlb_memWrite. Both reach vtlb_Miss, and each is its own emitter.
TEST(EeRecTlbDivert, DISABLED_DynamicBaseLoadMissDivertsToTheVector)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.SetGpr64(reg::a0, kUnmapped); // seeded, so not compile-time const
h.LoadProgram({
LW(reg::v1, 0, reg::a0), // +0x0 TLB refill miss
ADDIU(reg::v0, reg::zero, 99), // +0x4 must not execute
});
h.Run();
h.ExpectGpr64(reg::v0, 0ull);
h.ExpectGpr64(reg::v1, 0ull);
EXPECT_EQ(h.GetCp0Interp(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Interp(14), RecompilerTestEnvironment::kProgramPc);
EXPECT_EQ(h.GetCp0Jit(13) & 0xFFu, kCauseTlbL);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc)
<< "JIT EPC = the faulting load";
EXPECT_EQ(h.GetCp0Jit(8), kUnmapped);
}
TEST(EeRecTlbDivert, DISABLED_DynamicBaseStoreMissDivertsToTheVector)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.SetGpr64(reg::a0, kUnmapped);
h.SetGpr64(reg::a1, 0x1234);
h.LoadProgram({
SW(reg::a1, 0, reg::a0), // +0x0 TLB refill miss on a store
ADDIU(reg::v0, reg::zero, 99), // +0x4 must not execute
});
h.Run();
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Interp(13) & 0xFFu, kCauseTlbS);
EXPECT_EQ(h.GetCp0Interp(14), RecompilerTestEnvironment::kProgramPc);
EXPECT_EQ(h.GetCp0Jit(13) & 0xFFu, kCauseTlbS);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc)
<< "JIT EPC = the faulting store";
}
// The generic path with computed rather than folded values, so nothing here
// comes out of the const table. Not enough pressure to reach x28 — see the
// register-pressure case below for the one that actually needs the flush.
TEST(EeRecTlbDivert, DISABLED_DynamicWritesBeforeTheMissSurviveTheDivert)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.SetGpr64(reg::a0, kUnmapped);
h.SetGpr64(reg::a1, 7);
h.LoadProgram({
ADDIU(reg::t0, reg::a1, 4), // +0x0 } computed from a seeded reg;
ADDU(reg::t1, reg::t0, reg::a1), // +0x4 } no const folding, so these
ADDIU(reg::t2, reg::t1, 1), // +0x8 } live in host registers
LW(reg::v1, 0, reg::a0), // +0xC TLB refill miss
ADDIU(reg::v0, reg::zero, 99), // +0x10 must not execute
});
h.Run();
h.ExpectGpr64(reg::t0, 11ull);
h.ExpectGpr64(reg::t1, 18ull);
h.ExpectGpr64(reg::t2, 19ull);
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + 0xC)
<< "JIT EPC = the faulting load";
}
// The shape that decides how the divert has to be built. Reaching the vector
// with guest state complete is most of the work, and the reason is narrow:
// iFlushCall(FLUSH_VTLB), which every inline vtlb path already runs before the
// access, frees the caller-saved host registers but not x28 — the allocator's
// one callee-saved host. A guest register that landed there is live and dirty
// when the miss fires, and a bare jump to DispatcherReg drops it. A boot of the
// RA ISO with fastmem off reaches that state at 489 sites and nothing else was
// ever dirty there.
//
// It takes 16 live guest values at once to make the allocator reach x28, which
// is why the smaller cases in this file do not exercise the writeback at all.
TEST(EeRecTlbDivert, DISABLED_RegisterPressureWritesSurviveTheDivert)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.SetGpr64(reg::a2, kUnmapped); // base, and the source of every value below
std::vector<u32> program;
// r7 (a3), r8-r15 (t0-t7), r17-r23 (s1-s7): 16 unpinned registers, all
// computed rather than folded, all still live across the load.
const u32 dirty[] = {7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 23};
for (u32 i = 0; i < std::size(dirty); i++)
program.push_back(ADDIU(dirty[i], reg::a2, static_cast<s16>(i + 1)));
const u32 miss_off = static_cast<u32>(program.size()) * 4;
program.push_back(LW(reg::v1, 0, reg::a2)); // TLB refill miss
program.push_back(ADDIU(reg::v0, reg::zero, 99)); // must not execute
h.LoadProgram(program);
h.Run();
for (u32 i = 0; i < std::size(dirty); i++)
h.ExpectGpr64(dirty[i], static_cast<u64>(kUnmapped + i + 1));
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + miss_off)
<< "JIT EPC = the faulting load";
}
// The const-folded twin of the pressure case above. Both the writes and the
// address fold at compile time, so nothing is allocator-resident at the miss
// and the writeback is not what carries them. It is here for the const path's
// own coverage of "the block stops at the faulting load".
TEST(EeRecTlbDivert, DISABLED_WritesBeforeTheMissSurviveTheDivert)
{
SoftmemScope softmem;
EeRecTestHarness h;
h.LoadProgram({
LUI(reg::a0, kUnmapped >> 16), // +0x0
ADDIU(reg::t0, reg::zero, 11), // +0x4 } retired before the miss;
ADDIU(reg::t1, reg::zero, 22), // +0x8 } must be architecturally
ADDIU(reg::t2, reg::zero, 33), // +0xC } visible to the handler
LW(reg::v1, 0, reg::a0), // +0x10 TLB refill miss
ADDIU(reg::v0, reg::zero, 99), // +0x14 must not execute
});
h.Run();
h.ExpectGpr64(reg::t0, 11ull);
h.ExpectGpr64(reg::t1, 22ull);
h.ExpectGpr64(reg::t2, 33ull);
h.ExpectGpr64(reg::v0, 0ull);
EXPECT_EQ(h.GetCp0Jit(14), RecompilerTestEnvironment::kProgramPc + 0x10)
<< "JIT EPC = the faulting load";
}