SL-03: arm64 EE rec: superblocks — forward conditionals become continuation sites

The block scanner no longer ends a block at a forward conditional branch
(BEQ/BNE/BLEZ/BGTZ/BLTZ/BGEZ; non-likely, non-link, non-BCx): it records a
continuation site and keeps scanning at the fallthrough, so the not-taken
path compiles as one straight line — no pc store, no event check, no
linked-B, no next-head reload; register and constant residency ride through
the former boundary. The taken arm becomes a cold side exit outlined after
the block tail: it snapshots the compile state at the branch
(BranchCompileState, per pending exit), and its emission restores the
snapshot, compiles the taken-path delay slot (unless TrySwapDelaySlot
already hoisted it), and ends with the normal SetBranchImm flush + event +
linked-B tail. Compare shapes mirror recSetBranchEQ/recSetBranchL with the
sense inverted (branch out when TAKEN), const fast paths included.

Analysis stays exactly as conservative as today's block ends at each former
boundary: the liveness backward pass merges all-live at the branch and its
delay slot (the taken path leaves the block there), and the COP2
deferred-commit passes run per segment delimited at sites. A backward-split
landing on a site's delay slot clamps to the branch instead (a split pair
would leave the delay slot outside the analyzed range). Event-check
coarsening equals today's straight-line blocks: one check per exit, range
still capped by the 4K page. Caps: 8 sites, 128 insns per block.

Composition with SL-01: loops whose body contains a forward conditional
were previously split at it and could never form a self-loop — now they
fuse into one block and the loop-residency preheader/back-edge applies
(pinned by LoopWithInternalForwardBranchBecomesResident).

Deliberate non-sites: backward branches (loops keep the SL-01 shape),
likely variants (taken-only delay slot — different continuation shape),
BEQ rs==rt (unconditional idiom), branch-class delay slots, compile-time
const-resolved-taken. BNE rs==rt and const-resolved-not-taken continue
with no side exit.

Tests: ee_rec_superblock_tests (formation via recEeBlockGuestSize, both
runtime paths vs interp, delay-slot-both-paths, dirty-state flush at taken
exits incl. NEON quads, const propagation, multi-site, cap, SMC in the
fused range, memory traffic across a site); full recompiler_tests
1336/1336.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-18 08:34:06 -07:00
co-authored by Claude Fable 5
parent 8c8dc89e2b
commit 151b93fde9
7 changed files with 798 additions and 10 deletions
+231 -6
View File
@@ -1632,6 +1632,101 @@ void LoadBranchState()
s_savedBranchState.restore();
}
// =====================================================================================================
// SL-03: superblocks — conditional-fallthrough continuation
// =====================================================================================================
// The scanner records forward conditional branches (BEQ/BNE/BLEZ/BGTZ/BLTZ/
// BGEZ; non-likely, non-link) as continuation sites and keeps scanning at the
// fallthrough, so the not-taken path compiles as one straight line: no pc
// store, no event check, no linked-B, no next-head reload — register/const
// residency rides through the former boundary. The taken arm becomes a cold
// side exit outlined after the block tail; it snapshots the compile state at
// the branch, and its emission restores that snapshot, compiles the taken-path
// delay slot (unless TrySwapDelaySlot already hoisted it), and ends with the
// normal SetBranchImm tail. Analysis stays exactly as conservative as today's
// block ends: the liveness backward pass merges all-live at each site (the
// taken path leaves the block there), and the COP2 deferred-commit passes run
// per segment delimited at sites. Event-check coarsening equals today's
// straight-line blocks (one check per exit, range capped by the 4K page).
static constexpr int kMaxContSites = 8;
static constexpr u32 kMaxSuperblockInsns = 128;
static u32 s_contSitePcs[kMaxContSites]; // ascending (scan order)
static int s_numContSites = 0;
struct SuperblockSideExit
{
std::unique_ptr<a64::Label> label;
u32 branchTo;
u32 dsPc;
bool needDs;
BranchCompileState state;
};
static SuperblockSideExit s_sideExits[kMaxContSites];
static int s_numSideExits = 0;
bool recSuperblockIsContSite(u32 branch_pc)
{
for (int i = 0; i < s_numContSites; i++)
if (s_contSitePcs[i] == branch_pc)
return true;
return false;
}
// Snapshot the compile state for the taken path and hand back the label the
// handler's inverted condition branches to. Call after _eeFlushAllDirty and
// (for a swapped slot) after the delay-slot emission; the compare itself is a
// pure post-flush read and may be emitted after the snapshot. pc points at the
// delay slot here.
a64::Label* recSuperblockAddSideExit(u32 branch_target, bool need_delay_slot)
{
pxAssert(s_numSideExits < kMaxContSites);
SuperblockSideExit& x = s_sideExits[s_numSideExits++];
x.label = std::make_unique<a64::Label>();
x.branchTo = branch_target;
x.dsPc = pc;
x.needDs = need_delay_slot;
x.state.capture();
return x.label.get();
}
// Emit every pending cold side exit after the block tail. Each restores its
// branch-point snapshot, so the exit charges exactly the branch-point cycles
// and SetBranchImm's flush writes back exactly what was live-dirty there.
// The mainline pc/size/recRAMCopy bookkeeping ran before this — pc is dead.
static void recEmitPendingSideExits()
{
const int n = s_numSideExits;
s_numSideExits = 0;
for (int k = 0; k < n; k++)
{
SuperblockSideExit& x = s_sideExits[k];
armAsm->Bind(x.label.get());
x.state.restore();
g_branch = 0;
if (x.needDs)
{
pc = x.dsPc;
recompileNextInstruction(true, false);
}
SetBranchImm(x.branchTo);
x.label.reset();
}
g_branch = 1;
}
// Liveness barrier for the backward pass: `addr` is a continuation branch or
// its delay slot. The taken path leaves the block after the delay slot, so no
// dead-value assumption may cross either out-state.
static bool recSuperblockLivenessBarrier(u32 addr)
{
for (int i = 0; i < s_numContSites; i++)
if (addr == s_contSitePcs[i] || addr == s_contSitePcs[i] + 4)
return true;
return false;
}
// =====================================================================================================
// Instruction recompilation
// =====================================================================================================
@@ -2710,6 +2805,15 @@ bool recEeLoopBackedgeInfo(u32 pc_query, uptr* site, uptr* stub)
return true;
}
// SL-03 introspection: guest-insn size of the compiled block starting at
// pc_query (0 = no block), so tests can assert superblock formation (the
// range spans past a continuation branch) vs termination (it doesn't).
u32 recEeBlockGuestSize(u32 pc_query)
{
BASEBLOCKEX* b = recBlocks.Get(HWADDR(pc_query));
return b ? b->size : 0;
}
// Test-harness recLUT coverage introspection: does this guest PC dispatch to
// a real (compile-on-first-hit) LUT page rather than UnmappedRecLUTPage?
// The harness can't execute code from ROM regions (it's hardwired to EE
@@ -2850,6 +2954,63 @@ static bool recSkipTimeoutLoop(s32 reg, bool is_timeout_loop)
// Main Recompilation Loop
// =====================================================================================================
// Scanner-side decode: is `code` any branch/jump/eret/syscall-class op (a
// block-ender or continuation candidate)? Used to refuse continuation through
// a branch whose delay slot is itself a branch (architecturally-UB shape) —
// those fall back to ending the block, which is today's behavior.
static bool eeScanInsnIsBranchClass(u32 code)
{
switch (code >> 26)
{
case 0: // SPECIAL
{
const u32 funct = code & 0x3f;
return funct == 8 || funct == 9 || funct == 12 || funct == 13; // JR/JALR/SYSCALL/BREAK
}
case 1: // REGIMM
{
const u32 rt = (code >> 16) & 0x1f;
return rt < 4 || (rt >= 16 && rt < 20);
}
case 2: case 3: // J, JAL
case 4: case 5: case 6: case 7: // BEQ, BNE, BLEZ, BGTZ
case 20: case 21: case 22: case 23: // likely forms
return true;
case 16: // COP0: BC0x or ERET
return ((code >> 21) & 0x1f) == 8 ||
(((code >> 21) & 0x1f) == 16 && (code & 0x3f) == 24);
case 17: case 18: // COP1/COP2: BCx
return ((code >> 21) & 0x1f) == 8;
}
return false;
}
// Scanner-side continuation gate for a conditional branch at `i` targeting
// `target`. Forward-only (backward keeps the split/end logic), bounded, and
// refuses a branch-class delay slot.
static bool eeScanContinuable(u32 startpc, u32 i, u32 target)
{
return target > i + 4 &&
s_numContSites < kMaxContSites &&
((i + 8 - startpc) / 4) < kMaxSuperblockInsns &&
!eeScanInsnIsBranchClass(memRead32(i + 4));
}
// A backward-split target that lands exactly on a continuation site's delay
// slot would leave that branch as the last insn of the range with its delay
// slot outside the analyzed block (instinfo overrun, split-pair emission).
// End the block before the branch instead; the split's purpose — making the
// target a block start — is preserved (a block starting at the delay-slot
// address compiles it as a plain instruction, which is the architectural
// meaning of branching into a delay slot).
static u32 eeSuperblockClampSplit(u32 target)
{
for (int k = 0; k < s_numContSites; k++)
if (target == s_contSitePcs[k] + 4)
return s_contSitePcs[k];
return target;
}
static void recRecompile(const u32 startpc)
{
u32 i;
@@ -3036,6 +3197,7 @@ static void recRecompile(const u32 startpc)
s_nEndBlock = 0xffffffff;
s_branchTo = -1;
s_branchLoopable = false;
s_numContSites = 0;
// Timeout loop detection (matches x86 recSkipTimeoutLoop pattern):
// addiu reg,reg,-N / nop*N / bne reg,zero,loop / nop
@@ -3106,13 +3268,22 @@ static void recRecompile(const u32 startpc)
{
// rt 16-19 are the AL link variants — call-shaped tails
// (SetBranchImmCall), not back-edge candidates.
// SL-03: forward BLTZ/BGEZ (rt 0/1) become continuation
// sites — scan on at the fallthrough. Likely + AL forms
// keep ending the block.
if (_Rt_ < 2 && eeScanContinuable(startpc, i, _Imm_ * 4 + i + 4))
{
s_contSitePcs[s_numContSites++] = i;
i += 8; // skip the delay slot word in the scan
continue;
}
s_branchLoopable = _Rt_ < 4;
s_branchTo = _Imm_ * 4 + i + 4;
// Backward branch into the current block: end the block at the
// target so the loop head becomes its own linkable block.
// Mirrors x86 iR5900.cpp:2362 and the COP1/COP2 case below.
if (s_branchTo > startpc && s_branchTo < i)
s_nEndBlock = s_branchTo;
s_nEndBlock = eeSuperblockClampSplit(s_branchTo);
else
{
s_nEndBlock = i + 8;
@@ -3128,6 +3299,18 @@ static void recRecompile(const u32 startpc)
goto StartRecomp;
case 4: case 5: case 6: case 7: // BEQ, BNE, BLEZ, BGTZ
// SL-03: forward conditionals become continuation sites — scan
// on at the fallthrough. BEQ rs==rt is the unconditional-`b`
// idiom (always taken: everything after is unreachable on the
// fallthrough) and keeps ending the block.
if (!((cpuRegs.code >> 26) == 4 && _Rs_ == _Rt_) &&
eeScanContinuable(startpc, i, _Imm_ * 4 + i + 4))
{
s_contSitePcs[s_numContSites++] = i;
i += 8; // skip the delay slot word in the scan
continue;
}
[[fallthrough]];
case 20: case 21: // BEQL, BNEL
case 22: case 23: // BLEZL, BGTZL
s_branchLoopable = true;
@@ -3136,7 +3319,7 @@ static void recRecompile(const u32 startpc)
// is its own linkable block. Mirrors x86 iR5900.cpp:2387 and
// the COP1/COP2 case below.
if (s_branchTo > startpc && s_branchTo < i)
s_nEndBlock = s_branchTo;
s_nEndBlock = eeSuperblockClampSplit(s_branchTo);
else
{
s_nEndBlock = i + 8;
@@ -3159,7 +3342,7 @@ static void recRecompile(const u32 startpc)
s_branchLoopable = true;
s_branchTo = _Imm_ * 4 + i + 4;
if (s_branchTo > startpc && s_branchTo < i)
s_nEndBlock = s_branchTo;
s_nEndBlock = eeSuperblockClampSplit(s_branchTo);
else
{
s_nEndBlock = i + 8;
@@ -3174,6 +3357,14 @@ static void recRecompile(const u32 startpc)
StartRecomp:
// SL-03: a backward-split can truncate s_nEndBlock below already-recorded
// continuation sites — drop any site whose branch+delay-slot pair no longer
// fits inside [startpc, s_nEndBlock). (Sites are ascending; the compile
// loop never reaches a dropped one, but the analysis passes below must not
// treat it as an exit boundary either.)
while (s_numContSites > 0 && s_contSitePcs[s_numContSites - 1] + 8 > s_nEndBlock)
s_numContSites--;
// Self-modifying code detection: generate inline memory checks for manual blocks.
const bool is_manual_block = memory_protect_recompiled_code(startpc, (s_nEndBlock - startpc) >> 2);
@@ -3306,6 +3497,18 @@ StartRecomp:
for (i = s_nEndBlock; i > startpc; i -= 4)
{
cpuRegs.code = memRead32(i - 4);
// SL-03: at a continuation site the taken path leaves the block
// after the delay slot — merge "everything live" (the block-end
// init state) into the out-state of both the branch and its delay
// slot so no dead-value assumption crosses the side exit. This is
// exactly today's block-end conservatism at the former boundary.
if (recSuperblockLivenessBarrier(i - 4))
{
memset(pcur->regs, EEINST_LIVE, sizeof(pcur->regs));
memset(pcur->fpuregs, EEINST_LIVE, sizeof(pcur->fpuregs));
memset(pcur->vfregs, EEINST_LIVE, sizeof(pcur->vfregs));
memset(pcur->viregs, EEINST_LIVE, sizeof(pcur->viregs));
}
pcur[-1] = pcur[0];
recBackpropBSC(cpuRegs.code, pcur - 1, pcur);
pcur--;
@@ -3315,12 +3518,27 @@ StartRecomp:
// Run COP2 analysis passes — sets EEINST_COP2_SYNC_VU0/FINISH_VU0 flags
// for conditional VU0 synchronization in transfer ops.
// SL-03: run per segment, delimited at continuation sites. Both passes
// defer commits forward (flag-hack elision, micro-finish placement); a
// deferral must not cross a side exit that can escape before the
// superseding instruction executes. Per-segment == today's per-block
// semantics at each former boundary.
if (has_cop2_instructions)
{
R5900::COP2MicroFinishPass().Run(startpc, s_nEndBlock, s_pInstCache + 1);
u32 seg_start = startpc;
for (int k = 0; k <= s_numContSites; k++)
{
const u32 seg_end = (k < s_numContSites) ? (s_contSitePcs[k] + 8) : s_nEndBlock;
if (seg_end <= seg_start)
continue;
EEINST* const seg_inst = s_pInstCache + 1 + (seg_start - startpc) / 4;
R5900::COP2MicroFinishPass().Run(seg_start, seg_end, seg_inst);
if (EmuConfig.Speedhacks.vuFlagHack)
R5900::COP2FlagHackPass().Run(startpc, s_nEndBlock, s_pInstCache + 1);
if (EmuConfig.Speedhacks.vuFlagHack)
R5900::COP2FlagHackPass().Run(seg_start, seg_end, seg_inst);
seg_start = seg_end;
}
}
}
@@ -3340,6 +3558,9 @@ StartRecomp:
// Sweep any LDL/LDR fusion residue from an aborted prior compile (the
// per-pair gate otherwise guarantees same-block consume).
g_eeUnalignedFused = false;
// SL-03: sweep side-exit residue the same way (recEmitPendingSideExits
// drains the list on every completed compile).
s_numSideExits = 0;
// SL-1 candidacy: a resident self-loop is a block whose terminal branch
// targets its own startpc. Wait-loop-FF blocks keep the fast-forward
@@ -3505,6 +3726,10 @@ StartRecomp:
}
}
// SL-03: outline the cold taken side exits after the tail (mainline pc is
// dead — size and recRAMCopy bookkeeping ran on it above).
recEmitPendingSideExits();
pxAssert(armGetCurrentCodePointer() < SysMemory::GetEERecEnd());
// Size is from the aligned block_fnptr, not the pre-alignment recPtr —
+12
View File
@@ -657,6 +657,18 @@ bool TrySwapDelaySlot(u32 rs, u32 rt, u32 rd, bool allow_loadstore);
void SaveBranchState();
void LoadBranchState();
// SL-03 superblocks: conditional-fallthrough continuation. The block scanner
// records forward conditional branches as continuation sites instead of ending
// the block there. A site's handler queries recSuperblockIsContSite(pc-4) and
// emits the inverted shape — branch OUT to a cold taken side exit, compile the
// delay slot inline, and fall through into the rest of the block — instead of
// the two-arm terminal fork. Side exits snapshot the compile state at the
// branch and are outlined after the block tail (recEmitPendingSideExits),
// where each restores its snapshot, compiles the taken-path delay slot, and
// emits the normal flush + event-check + linked-B tail.
bool recSuperblockIsContSite(u32 branch_pc);
vixl::aarch64::Label* recSuperblockAddSideExit(u32 branch_target, bool need_delay_slot);
void recompileNextInstruction(bool delayslot, bool swapped_delay_slot);
// Block-tail transfer for register-indirect targets (recJR/recJALR).
+144 -4
View File
@@ -136,6 +136,122 @@ static void recBindBranchLabel()
s_pBranchLabel = nullptr;
}
// =====================================================================================================
// SL-03 superblock continuation — inverted branch emission
// =====================================================================================================
// At a continuation site the block does NOT end: the handler emits the
// TAKEN-condition branch out to a cold side exit (recorded for outlined
// emission after the block tail), compiles the not-taken delay slot inline,
// and returns with g_branch clear so the main loop keeps compiling at the
// fallthrough. Compare shapes mirror recSetBranchEQ/recSetBranchL with the
// condition sense inverted (branch out when TAKEN instead of skip when
// not-taken), const fast paths included.
// BEQ/BNE continuation. Returns true when fully handled (caller returns).
static bool recTrySuperblockContinueEQ(bool is_bne)
{
if (!recSuperblockIsContSite(pc - 4))
return false;
const u32 branchTo = ((s32)_Imm_ * 4) + pc;
if (GPR_IS_CONST2(_Rs_, _Rt_))
{
const bool taken = is_bne ? (g_cpuConstRegs[_Rs_].SD[0] != g_cpuConstRegs[_Rt_].SD[0]) :
(g_cpuConstRegs[_Rs_].SD[0] == g_cpuConstRegs[_Rt_].SD[0]);
if (taken)
return false; // resolved-taken: terminal — the const path emits it
recompileNextInstruction(true, false); // resolved fallthrough: no exit
return true;
}
if (_Rs_ == _Rt_)
{
if (!is_bne)
return false; // BEQ rs==rt: always taken (scanner excludes; defensive)
recompileNextInstruction(true, false); // BNE rs==rt: never taken
return true;
}
const int process = GPR_IS_CONST1(_Rs_) ? PROCESS_CONSTS :
(GPR_IS_CONST1(_Rt_) ? PROCESS_CONSTT : 0);
const bool swap = TrySwapDelaySlot(_Rs_, _Rt_, 0, true);
_eeFlushAllDirty();
a64::Label* const exit = recSuperblockAddSideExit(branchTo, !swap);
if (process)
{
const int constReg = (process & PROCESS_CONSTS) ? _Rs_ : _Rt_;
const int liveReg = (process & PROCESS_CONSTS) ? _Rt_ : _Rs_;
const s64 cval = g_cpuConstRegs[constReg].SD[0];
const a64::Register live = loadGPRtoX(RXARG1, liveReg);
if (cval == 0)
{
// BEQ taken ⇔ live == 0 → Cbz; BNE taken ⇔ live != 0 → Cbnz.
if (is_bne)
armAsm->Cbnz(live, exit);
else
armAsm->Cbz(live, exit);
}
else
{
armAsm->Cmp(live, cval);
armAsm->B(exit, is_bne ? a64::ne : a64::eq);
}
}
else
{
const a64::Register rs = loadGPRtoX(RXARG1, _Rs_);
const a64::Register rt = loadGPRtoX(RXSCRATCH, _Rt_);
armAsm->Cmp(rs, rt);
armAsm->B(exit, is_bne ? a64::ne : a64::eq);
}
if (!swap)
recompileNextInstruction(true, false);
return true;
}
// BLEZ/BGTZ/BLTZ/BGEZ continuation; taken_cond is the TAKEN sense (le/gt/lt/ge).
static bool recTrySuperblockContinueSingle(a64::Condition taken_cond)
{
if (!recSuperblockIsContSite(pc - 4))
return false;
const u32 branchTo = ((s32)_Imm_ * 4) + pc;
if (GPR_IS_CONST1(_Rs_))
{
const s64 val = g_cpuConstRegs[_Rs_].SD[0];
bool taken = false;
if (taken_cond == a64::le) taken = (val <= 0);
else if (taken_cond == a64::gt) taken = (val > 0);
else if (taken_cond == a64::lt) taken = (val < 0);
else if (taken_cond == a64::ge) taken = (val >= 0);
if (taken)
return false; // resolved-taken: terminal — the const path emits it
recompileNextInstruction(true, false); // resolved fallthrough: no exit
return true;
}
const bool swap = TrySwapDelaySlot(_Rs_, 0, 0, true);
_eeFlushAllDirty();
a64::Label* const exit = recSuperblockAddSideExit(branchTo, !swap);
const a64::Register rs = loadGPRtoX(RXSCRATCH, _Rs_);
if (taken_cond == a64::lt)
armAsm->Tbnz(rs, 63, exit); // BLTZ taken ⇔ sign bit set
else if (taken_cond == a64::ge)
armAsm->Tbz(rs, 63, exit); // BGEZ taken ⇔ sign bit clear
else
{
armAsm->Cmp(rs, 0);
armAsm->B(exit, taken_cond);
}
if (!swap)
recompileNextInstruction(true, false);
return true;
}
//// BEQ — branch if rs == rt
static void recBEQ_const()
{
@@ -182,6 +298,8 @@ static void recBEQ_process(int process)
void recBEQ()
{
if (recTrySuperblockContinueEQ(false))
return;
if (GPR_IS_CONST2(_Rs_, _Rt_))
recBEQ_const();
else if (GPR_IS_CONST1(_Rs_))
@@ -238,6 +356,8 @@ static void recBNE_process(int process)
void recBNE()
{
if (recTrySuperblockContinueEQ(true))
return;
if (GPR_IS_CONST2(_Rs_, _Rt_))
recBNE_const();
else if (GPR_IS_CONST1(_Rs_))
@@ -433,10 +553,30 @@ static void recBranchSingleLikely(a64::Condition skip_cond)
SetBranchImm(pc);
}
void recBLEZ() { recBranchSingle(a64::gt); }
void recBGTZ() { recBranchSingle(a64::le); }
void recBLTZ() { recBranchSingle(a64::ge); }
void recBGEZ() { recBranchSingle(a64::lt); }
void recBLEZ()
{
if (recTrySuperblockContinueSingle(a64::le))
return;
recBranchSingle(a64::gt);
}
void recBGTZ()
{
if (recTrySuperblockContinueSingle(a64::gt))
return;
recBranchSingle(a64::le);
}
void recBLTZ()
{
if (recTrySuperblockContinueSingle(a64::lt))
return;
recBranchSingle(a64::ge);
}
void recBGEZ()
{
if (recTrySuperblockContinueSingle(a64::ge))
return;
recBranchSingle(a64::lt);
}
void recBLEZL() { recBranchSingleLikely(a64::gt); }
void recBGTZL() { recBranchSingleLikely(a64::le); }
@@ -41,6 +41,7 @@ add_pcsx2_test(recompiler_tests
ee_rec_alu_imm_tests.cpp
ee_rec_branch_tests.cpp
ee_rec_loop_residency_tests.cpp
ee_rec_superblock_tests.cpp
ee_rec_branch_in_delay_tests.cpp
ee_rec_callret_tests.cpp
ee_rec_cop0_tests.cpp
@@ -0,0 +1,402 @@
// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
// SL-03 superblocks: forward conditional branches become continuation sites —
// the not-taken path compiles straight through (no tail, no head reload), the
// taken arm becomes a cold side exit outlined after the block tail. These
// tests pin block formation (via recEeBlockGuestSize), both runtime paths
// (Run() diffs the full architectural state against the interpreter), the
// delay-slot-on-both-paths contract, snapshot-flush coherence at taken exits,
// the S1×S2 composition (a loop with an internal forward branch becomes one
// resident self-loop), and the deliberate non-sites (backward, likely,
// unconditional-idiom, cap overflow).
#include "harness/EeRecTestHarness.h"
#include "R5900.h"
#include "vtlb.h"
#include <gtest/gtest.h>
using namespace recompiler_tests;
using namespace mips;
extern u32 recEeBlockGuestSize(u32 pc_query);
extern bool recEeBlockIsLoopResident(u32 pc_query);
namespace {
constexpr u32 kPark = RecompilerTestEnvironment::kParkingPc;
constexpr u32 kProgPc = RecompilerTestEnvironment::kProgramPc;
constexpr u32 kDataAddr = 0x00020000; // EE RAM scratch, away from the program
// Formation-asserting tests need a deterministic page state (the harness
// program page can flip to ProtMode_Manual across accumulated write→clear
// cycles in this process; manual blocks still superblock, but a fresh state
// keeps size assertions honest) — same recipe as the loop-residency tests.
void ResetRecAndPageProtection()
{
recCpu.Reset();
mmap_ResetBlockTracking();
}
} // namespace
// A forward BNE no longer ends the block: the compiled range spans the branch,
// its delay slot, both the skipped and merge instructions, and the terminal J.
TEST(EeRecSuperblock, ForwardBranchFusesBlock)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0); // not taken
h.LoadProgramNoTerm({
BNE(reg::t0, reg::zero, 2), // idx0 → idx4 when taken
NOP, // idx1 ds
ADDIU(reg::t1, reg::zero, 5), // idx2 fallthrough-only
ADDIU(reg::t2, reg::zero, 7), // idx3 merge
J(kPark), NOP, // idx4/5
});
h.Run();
h.ExpectGpr64(reg::t1, 5ull);
h.ExpectGpr64(reg::t2, 7ull);
// Pre-SL-03 this block was 2 insns (branch + delay slot).
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 6u);
}
// Same program, condition true: the cold taken side exit runs — it must skip
// the fallthrough-only instruction and land on the merge point.
TEST(EeRecSuperblock, TakenSideExitCorrect)
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, 1); // taken
h.LoadProgramNoTerm({
BNE(reg::t0, reg::zero, 2),
NOP,
ADDIU(reg::t1, reg::zero, 5),
ADDIU(reg::t2, reg::zero, 7),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t1, 0ull);
h.ExpectGpr64(reg::t2, 7ull);
}
// The delay slot executes on BOTH paths: inline on the fallthrough, recompiled
// from the snapshot inside the side exit on the taken path.
TEST(EeRecSuperblock, DelaySlotExecutesOnBothPaths)
{
for (const u64 cond : {0ull, 1ull})
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, cond);
h.SetGpr64(reg::t3, 0);
h.LoadProgramNoTerm({
BNE(reg::t0, reg::zero, 2), // idx0
ADDIU(reg::t3, reg::t3, 11), // idx1 ds: both paths
ADDIU(reg::t1, reg::zero, 5), // idx2 fallthrough-only
ADDIU(reg::t2, reg::zero, 7), // idx3 merge
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t3, 11ull);
h.ExpectGpr64(reg::t1, cond ? 0ull : 5ull);
h.ExpectGpr64(reg::t2, 7ull);
}
}
// Dirty scalar + dirty 128-bit NEON state at the branch: the side exit's
// flush is emitted from the branch-point snapshot and must publish exactly
// that state before linking out (Run() diffs everything vs the interpreter).
TEST(EeRecSuperblock, TakenExitFlushesDirtyState)
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, 3);
h.SetGpr64(reg::t1, 4);
h.SetGpr128(reg::t4, 0x0000000100000002ull, 0x0000000300000004ull);
h.SetGpr128(reg::t5, 0x0000001000000010ull, 0x0000001000000010ull);
h.LoadProgramNoTerm({
ADDU(reg::t2, reg::t0, reg::t1), // t2 = 7, dirty resident
ee::PADDW(reg::t4, reg::t4, reg::t5), // NEON quad dirty
BNE(reg::t2, reg::zero, 2), // taken → side exit must flush t2/t4
NOP,
ADDIU(reg::t2, reg::zero, 0), // fallthrough-only (skipped)
ADDIU(reg::v0, reg::zero, 9),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 7ull);
h.ExpectGpr128(reg::t4, 0x0000001100000012ull, 0x0000001300000014ull);
h.ExpectGpr64(reg::v0, 9ull);
}
// A compile-time constant created before the branch stays const through the
// continuation — the value must still be architecturally correct on both
// paths (the taken exit materializes it in its flush).
TEST(EeRecSuperblock, ConstRidesThroughContinuation)
{
for (const u64 cond : {0ull, 1ull})
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, cond);
h.LoadProgramNoTerm({
ORI(reg::t2, reg::zero, 0x123), // const before the branch
BNE(reg::t0, reg::zero, 2),
NOP,
ADDIU(reg::t1, reg::zero, 5), // fallthrough-only
ADDU(reg::t3, reg::t2, reg::t2), // merge: uses the const
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 0x123ull);
h.ExpectGpr64(reg::t3, 0x246ull);
h.ExpectGpr64(reg::t1, cond ? 0ull : 5ull);
}
}
// Two continuation sites in one block, all four taken/not-taken combinations.
TEST(EeRecSuperblock, MultipleContinuationSites)
{
for (const u64 c0 : {0ull, 1ull})
{
for (const u64 c1 : {0ull, 1ull})
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, c0);
h.SetGpr64(reg::t1, c1);
h.LoadProgramNoTerm({
BNE(reg::t0, reg::zero, 2), // idx0 → idx3 (skip the t2 add, land ON the 2nd branch)
NOP,
ADDIU(reg::t2, reg::t2, 1), // idx2: only when !c0
BNE(reg::t1, reg::zero, 2), // idx3 → idx6 (skip the t3 add) — both paths reach this
NOP,
ADDIU(reg::t3, reg::t3, 1), // idx5: only when !c1
NOP, // idx6
ADDIU(reg::v0, reg::zero, 9), // idx7
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, c0 ? 0ull : 1ull);
h.ExpectGpr64(reg::t3, c1 ? 0ull : 1ull);
h.ExpectGpr64(reg::v0, 9ull);
}
}
}
// Backward conditionals still end the block (loops stay S1's shape).
TEST(EeRecSuperblock, BackwardBranchStillEndsBlock)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0);
h.SetGpr64(reg::t1, 3);
h.LoadProgramNoTerm({
// 0x00 loop:
ADDIU(reg::t0, reg::t0, 1),
ADDIU(reg::t1, reg::t1, -1),
BNE(reg::t1, reg::zero, -3), // → 0x00
NOP,
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t0, 3ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 4u); // loop only — not fused onward
}
// Likely branches keep ending the block (wave-1 scope: their delay slot is
// taken-path-only, a different continuation shape).
TEST(EeRecSuperblock, LikelyBranchStillEndsBlock)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0);
h.LoadProgramNoTerm({
ee::BNEL(reg::t0, reg::zero, 2), // forward likely — terminal
NOP,
ADDIU(reg::t1, reg::zero, 5),
ADDIU(reg::t2, reg::zero, 7),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t1, 5ull);
h.ExpectGpr64(reg::t2, 7ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 2u);
}
// BEQ rs==rt is the unconditional-`b` idiom: always taken, so the fallthrough
// is unreachable and the block must end there.
TEST(EeRecSuperblock, UnconditionalBeqIdiomEndsBlock)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.LoadProgramNoTerm({
BEQ(reg::zero, reg::zero, 2), // b → idx4
NOP,
ADDIU(reg::t1, reg::zero, 5), // unreachable
NOP,
ADDIU(reg::v0, reg::zero, 9), // idx4
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t1, 0ull);
h.ExpectGpr64(reg::v0, 9ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 2u);
}
// BNE rs==rt never takes: pure fallthrough, fused with no side exit.
TEST(EeRecSuperblock, NeverTakenBneSameRegContinues)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 42);
h.LoadProgramNoTerm({
BNE(reg::t0, reg::t0, 2),
NOP,
ADDIU(reg::t1, reg::zero, 5),
ADDIU(reg::t2, reg::zero, 7),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t1, 5ull);
h.ExpectGpr64(reg::t2, 7ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 6u);
}
// Compile-time const-resolved-taken branch is terminal (everything after is
// unreachable on this compile); const-resolved-not-taken continues inline.
TEST(EeRecSuperblock, ConstResolvedTakenEndsBlock)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.LoadProgramNoTerm({
ORI(reg::t0, reg::zero, 1), // t0 = const 1
BNE(reg::t0, reg::zero, 2), // resolved taken → idx4
NOP,
ADDIU(reg::t2, reg::zero, 7), // unreachable
ADDIU(reg::v0, reg::zero, 9), // idx4
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 0ull);
h.ExpectGpr64(reg::v0, 9ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 3u); // ORI + BNE + ds
}
TEST(EeRecSuperblock, ConstResolvedNotTakenContinues)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.LoadProgramNoTerm({
ORI(reg::t0, reg::zero, 0), // t0 = const 0
BNE(reg::t0, reg::zero, 2), // resolved not-taken → falls through
NOP,
ADDIU(reg::t2, reg::zero, 7),
ADDIU(reg::v0, reg::zero, 9),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 7ull);
h.ExpectGpr64(reg::v0, 9ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 7u);
}
// The per-block site cap (8): the ninth forward conditional ends the block.
TEST(EeRecSuperblock, SiteCapEndsBlockAtNinthBranch)
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0);
std::vector<u32> prog;
for (int i = 0; i < 9; i++)
{
prog.push_back(BNE(reg::t0, reg::zero, 1)); // → its own fallthrough
prog.push_back(NOP);
}
prog.push_back(ADDIU(reg::v0, reg::zero, 9));
prog.push_back(J(kPark));
prog.push_back(NOP);
h.LoadProgramNoTerm(prog);
h.Run();
h.ExpectGpr64(reg::v0, 9ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 18u); // 8 fused pairs + terminal pair
}
// The S1×S2 composition prize: a loop whose body contains a forward
// conditional used to be split into two blocks — now it is ONE self-loop
// superblock and SL-1 makes it loop-resident. Both body paths iterate.
TEST(EeRecSuperblock, LoopWithInternalForwardBranchBecomesResident)
{
for (const u64 skip : {0ull, 1ull})
{
ResetRecAndPageProtection();
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0);
h.SetGpr64(reg::t1, 5);
h.SetGpr64(reg::t2, 0);
h.SetGpr64(reg::t3, skip);
h.LoadProgramNoTerm({
// 0x00 loop:
ADDIU(reg::t0, reg::t0, 1), // idx0: iteration count
BNE(reg::t3, reg::zero, 2), // idx1: forward → idx4 (skip the add)
NOP, // idx2 ds
ADDIU(reg::t2, reg::t2, 1), // idx3: only when !skip
ADDIU(reg::t1, reg::t1, -1), // idx4: merge
BNE(reg::t1, reg::zero, -6), // idx5: → 0x00
NOP, // idx6 ds
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t0, 5ull);
h.ExpectGpr64(reg::t1, 0ull);
h.ExpectGpr64(reg::t2, skip ? 0ull : 5ull);
EXPECT_EQ(recEeBlockGuestSize(kProgPc), 7u) << "loop body not fused";
EXPECT_TRUE(recEeBlockIsLoopResident(kProgPc)) << "fused loop not resident";
}
}
// SMC anywhere in the fused range must invalidate the whole superblock: a
// rewrite AFTER the continuation branch changes the recompiled semantics.
TEST(EeRecSuperblock, SmcInFusedRegionRecompiles)
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, 0);
h.LoadProgramNoTerm({
BNE(reg::t0, reg::zero, 2), // idx0
NOP,
ADDIU(reg::t1, reg::zero, 5), // idx2
ADDIU(reg::t2, reg::zero, 7), // idx3 ← rewritten below
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 7ull);
h.TriggerSmc(kProgPc + 3 * 4, ADDIU(reg::t2, reg::zero, 0x55));
h.SetGpr64(reg::t0, 0);
h.Run(EeRecTestHarness::RunMode::PreserveCache);
h.ExpectGpr64(reg::t2, 0x55ull);
}
// Store/load traffic straddling a continuation site: inline fastmem stores
// publish before the branch, loads after it read them, both paths.
TEST(EeRecSuperblock, MemoryTrafficAcrossContinuation)
{
for (const u64 cond : {0ull, 1ull})
{
EeRecTestHarness h;
h.SetGpr64(reg::t0, cond);
h.SetGpr64(reg::t4, kDataAddr);
h.SetGpr64(reg::t1, 0x1234);
h.TrackMemWindow(kDataAddr, 8);
h.LoadProgramNoTerm({
SW(reg::t1, 0, reg::t4), // idx0
BNE(reg::t0, reg::zero, 2), // idx1 → idx5
NOP,
ADDIU(reg::t1, reg::t1, 1), // idx3: only when !cond
NOP, // idx4
LW(reg::t2, 0, reg::t4), // idx5: reads the pre-branch store
SW(reg::t1, 4, reg::t4),
J(kPark), NOP,
});
h.Run();
h.ExpectGpr64(reg::t2, 0x1234ull);
EXPECT_EQ(h.ReadU32(kDataAddr + 4), cond ? 0x1234u : 0x1235u);
}
}
@@ -256,6 +256,13 @@ void EeRecTestHarness::LoadProgramNoTerm(std::initializer_list<u32> instructions
LoadProgramImpl(instructions, /*append_term=*/false);
}
void EeRecTestHarness::LoadProgramNoTerm(const std::vector<u32>& instructions)
{
program_words_ = instructions;
for (size_t i = 0; i < program_words_.size(); ++i)
memWrite32(kProgramPc + static_cast<u32>(i * 4), program_words_[i]);
}
void EeRecTestHarness::SeedEntryState()
{
cpuRegs.GPR.n.ra.UD[0] = static_cast<s64>(static_cast<s32>(kParkingPc));
@@ -108,6 +108,7 @@ public:
void LoadProgram(std::initializer_list<u32> instructions);
void LoadProgram(const std::vector<u32>& instructions);
void LoadProgramNoTerm(std::initializer_list<u32> instructions);
void LoadProgramNoTerm(const std::vector<u32>& instructions);
// ---- Execute ----