ARM64: EE dispatcher + delay-slot block compiler (Phase 4.3)

The EE recompiler now actually runs guest code: Cpu = &recCpu boots the BIOS
on ARM64 (Mode Changed to DVD PAL, Pad DS2 Config Finished).

- aR5900.cpp: multi-instruction recCompileBlock (per-block x19+LR prologue/
  epilogue, straight-line ops, handled-branch + delay-slot terminator, length
  cap, exit-before-uncompilable-op); recEmitBranch decodes J/JAL/JR/JALR +
  BEQ/BNE/BLEZ/BGTZ + REGIMM B*Z[AL] onto the Phase 4.1/4.2 generators; C++
  recExecute dispatcher loop (pc -> recGetBlock -> run -> cycle add ->
  _cpuEventTest_Shared, fastjmp exit); recScaleBlockCycles; reset/exit plumbing.
- Interpreter.cpp/R5900.h: intExecuteOneInst() — per-opcode interpreter fallback
  for ops the rec can't compile (COP/FPU/MMI, syscalls, traps, likely/COP
  branches), mirroring execI with the interpreter's own branch/delay-slot handling.
- VMManager.cpp: select recCpu for EE on ARM64 when CHECK_EEREC (IOP+VU still int).

Key fix: blocks clobber LR via vtlb/interp calls, so each block saves/restores
LR itself (a shared enter-trampoline + bare block crashed on the block's RET).

unittests green; arm64 binary; BIOS-boot verified with the rec active.
This commit is contained in:
David Isztl
2026-06-04 14:19:32 +02:00
parent 5609729a93
commit 09fd682677
7 changed files with 497 additions and 65 deletions
+78
View File
@@ -28,6 +28,84 @@
---
## 2026-06-04 — Phase 4.3: dispatcher + delay-slot block compiler (the rec RUNS)
**Goal:** Make the EE recompiler actually execute guest code: a multi-instruction
block compiler that consumes the Phase 3/4 generators, a C++ dispatcher loop, a
per-opcode interpreter fallback, then flip `Cpu = &recCpu` and boot the BIOS.
**What changed:**
- `pcsx2/arm64/aR5900.cpp` — the big one:
- `recCompileBlock(startpc, *out_cycles)`: compile-time-PC loop. Emits a per-block
prologue (`Stp x19,x30` + `armMoveAddressToReg(RESTATEPTR,&cpuRegs)`), then
straight-line ops via `recTranslateOp`, terminating at the first **handled**
control-flow op (`recEmitBranch` → compile delay slot → exit), the length cap
(`MAX_BLOCK_INSTS=256`, writes pc=next), or just before any un-compilable op
(writes pc=that op). Epilogue restores x19+LR and RETs.
- `recEmitBranch(op, branchpc)`: decodes J/JAL/JR/JALR, BEQ/BNE/BLEZ/BGTZ, REGIMM
BLTZ/BGEZ/BLTZAL/BGEZAL → the Phase 4.1/4.2 generators with compile-time
target/fallthrough/link (using `_PC_ == branchpc+4`). `recIsHandledBranch`
mirrors the coverage for the block-terminator test.
- `recEmitInterpInline(op)`: delay-slot fallback — `Str` op into `cpuRegs.code`,
`armEmitCall` the interpreter handler (no PC write).
- `recExecute()`: `fastjmp_set` + `for(;;)` dispatcher: `recGetBlock(cpuRegs.pc)`
(compile or `s_blocks` cache) → run block → `cpuRegs.cycle += block.cycles`
`_cpuEventTest_Shared` when `cycle >= nextEventCycle`. Un-compilable starting op
`intExecuteOneInst()`. Exit via `fastjmp` on `eeRecExitRequested`.
- `recScaleBlockCycles` (ports `scaleblockcycles_calculation`), reset/exit plumbing
(`recResetRaw`/`recResetEE`/`recSafeExitExecution`/`recClear`, `eeRecExecuting`/
`eeRecNeedsReset`/`eeRecExitRequested`/`s_jmp_buf`).
- `pcsx2/Interpreter.cpp` + `R5900.h` — new public `intExecuteOneInst()`: interprets
one op at `cpuRegs.pc` (mirrors `execI`); for branch ops the interpreter's own
`doBranch` handles delay slot + PC + cycle flush, for everything else it flushes
cycles via `intUpdateCPUCycles`. The rec's per-opcode fallback.
- `pcsx2/arm64/aR5900.h``EE_CODE_OFFSET`.
- `pcsx2/VMManager.cpp``UpdateCPUImplementations` ARM64 branch now selects
`Cpu = CHECK_EEREC ? &recCpu : &intCpu` (IOP+VU stay interpreters).
- Commits: (committed with this journal entry)
**Decisions & rationale:**
- **Each block owns its x19+LR save/restore (no shared enter-trampoline).** First
attempt used a generated trampoline that set x19 and `Blr`'d a bare block — but
blocks make calls (vtlb load/store, inline interp) that clobber LR, so the block's
final `Ret` jumped to garbage → BIOS never progressed past VM init. Giving each
block the same prologue/epilogue as the `RunEEGen` unit-test harness (which already
proved this exact shape) fixed it: the BIOS booted immediately after.
- **Compiled blocks and interpreter single-steps never straddle a cycle/event
boundary.** The block compiler stops *before* any un-compilable op; the dispatcher
then runs that one op via `intExecuteOneInst`. So compiled-block cycles (rec-owned,
added to `cpuRegs.cycle` by the dispatcher) and interpreter-op cycles (interpreter-
owned via `cpuBlockCycles`/`doBranch`) stay cleanly separated — no double counting,
no entanglement. This sidesteps reimplementing the likely/COP/trap branches: they
just fall to the interpreter, which already encodes their delay-slot semantics.
- **Bring-up cache = flat `unordered_map<pc,block>`, recClear = full reset.** The
two-level recLUT + hardlinking (4.4) and targeted invalidation (4.5) are deferred;
correctness-first. Block cache cleared on every reset (`recResetRaw`).
- **Branch generators write `cpuRegs.pc` *before* the delay slot is compiled** (their
Phase 4.1/4.2 contract). The delay slot is compiled after and never writes pc, so
the committed target survives — verified by the BIOS boot exercising real branches.
**Blockers / open questions:**
- none blocking. Known approximations (all noted, none break execution): likely/COP
branches + FPU/COP/MMI + syscalls all single-step through the interpreter (slow but
correct); cycle timing drifts by ~the branch op's own cycle for interpreter-handled
branches; `recClear` nukes the whole cache.
**Verification:**
- `unittests` green (both `common_test` + `core_test`); arm64 binary confirmed.
- Headless BIOS boot (`-batch -bios`, no disc) with the rec as the active EE
provider: `emulog` shows `UpdateVSyncRate: Mode Changed to DVD PAL.` and
`Pad: DS2 Config Finished` — the same boot milestones the interpreter hit in
Phase 1.5 — then a clean pause/shutdown after 24s. The rec is executing real
guest code end-to-end. (Run recipe: copy app, `install_name_tool -change` the four
Qt libs to `@executable_path/../Frameworks`, `codesign --force --deep`, `gtimeout`.)
**Next step:** Phase 4.4 (block linking + recLUT to remove the per-block map lookup +
recompile-on-miss) or Phase 5.2 (COP1/FPU codegen — currently all interpreter
single-steps, heavily used). Both are now measurable against a running rec.
---
## 2026-06-04 — Phase 4.1 + 4.2: EE branch/jump codegen (generators)
**Goal:** Land the EE control-flow *codegen* — the jump and conditional-branch
+41 -31
View File
@@ -8,29 +8,38 @@
## ▶ CURRENT FOCUS
**Phase 03.5 COMPLETE. Phase 4.1 + 4.2 codegen DONE (generators only).**
EE branch/jump *codegen* lives in `pcsx2/arm64/aR5900Branch.cpp`:
- 4.1 jumps: `J/JAL/JR/JALR` — write `cpuRegs.pc` (+ link GPR for JAL/JALR).
- 4.2 conditional branches: `BEQ/BNE/BLTZ/BGEZ/BLEZ/BGTZ/BLTZAL/BGEZAL` — Cmp +
Csel select `pc = cond ? target : fallthrough`; *AL forms link unconditionally.
**Phase 04.3 COMPLETE. The EE recompiler now RUNS — `Cpu = &recCpu` boots the BIOS.**
The dispatcher + delay-slot block compiler are live in `pcsx2/arm64/aR5900.cpp`:
- `recCompileBlock(startpc)` compiles a straight-line run into one self-contained
host block (per-block prologue saves x19+LR and sets RESTATEPTR=&cpuRegs; epilogue
restores + RET). It stops at the first **handled** control-flow op (emits the
branch generator → compiles the delay slot → ends the block), at the length cap
(`MAX_BLOCK_INSTS`, writes pc=next), or just before any op it can't compile
(writes pc=that op).
- `recExecute()` is a C++ dispatcher loop: read `cpuRegs.pc``recGetBlock` (compile
or `s_blocks` cache hit) → run block → `cpuRegs.cycle += block.cycles`
`_cpuEventTest_Shared` when the event cycle is hit. Exit via `fastjmp` on
`eeRecExitRequested` (set by `recSafeExitExecution`).
- **Per-opcode interpreter fallback:** any op the rec can't compile (COP/FPU/MMI,
syscalls, traps, likely & COP branches, ...) is run one-at-a-time by the new
`intExecuteOneInst()` (Interpreter.cpp) — it mirrors `execI`, and for branch ops
the interpreter's own `doBranch` handles the delay slot + PC + cycle flush.
- Cycle scaling (`recScaleBlockCycles`) mirrors `iR5900.cpp` `scaleblockcycles`.
- **Verified:** BIOS boots through `Mode Changed to DVD PAL` + `Pad: DS2 Config
Finished` with the rec as the active EE provider; unittests green; arm64 binary.
These emit **only the control-flow effect** (the next-PC / link write). They are
verified 1:1 vs the interpreter by 16 new `Arm64EmitEE.*` gtests (jumps incl.
rd==rs / $zero edge cases; branches incl. 64-bit BEQ and rs==31 link timing).
Builds clean (pcsx2-qt arm64), unittests green. The generators are **not yet wired
into the block compiler** — `recCompileBlock`/`recExecute` are still inert (Phase
1.4 single-instr stub) and the interpreter is still the active Cpu provider.
Key bug fixed during bring-up: blocks make calls (vtlb/interp) that clobber LR, so
each block must save/restore LR itself (the bare-block + shared-trampoline design
crashed on the block's final RET). Now every block has its own x19+LR prologue/epilogue
(matching the `RunEEGen` test harness).
Next concrete task: **Phase 4.3 — the dispatcher + delay-slot block compiler**
(the architectural core that makes the rec actually run). This is the big one:
rewrite `recCompileBlock` into a multi-instruction loop with a compile-time PC that
runs straight-line ops until a branch, then compiles the branch generator + its
delay slot + block exit; add a C++ dispatcher loop in `recExecute` (read cpuRegs.pc
→ compile/run block → cpuEventTest); add **interpreter fallback** for every opcode
`recTranslateOp` returns false on (so it's safe to run); then flip `Cpu = &recCpu`
and validate on BIOS boot. Deferred within Phase 4: "likely" branches
(BEQL/BNEL/...) that nullify the delay slot, and block linking (4.4) / the recLUT
optimization.
Next concrete task: **Phase 4.3-likely or Phase 5.2 (FPU)** — pick the highest-leverage
follow-up. Candidates: (a) compile the "likely" branches (BEQL/BNEL/BLEZL/BGTZL/
BLTZL/BGEZL/BLTZALL/BGEZALL — delay-slot nullification) so they stop hitting the
interpreter; (b) **Phase 4.4 block linking + recLUT** to kill the per-block
`unordered_map` lookup + recompile-on-miss cost; (c) **Phase 5.2 COP1/FPU** (BIOS +
most games lean on it heavily and it's currently all interpreter single-steps).
Recommend (b) or (c) — both are now measurable against a running rec.
> When you finish a task, move this pointer to the next one and flip the box below.
@@ -87,17 +96,18 @@ still defers all real work to the interpreter. ✅ **DONE** (BIOS boot verified)
## Phase 4 — EE Branches & Jumps
- [~] 4.1 Jumps: `J/JAL/JR/JALR`**codegen done** (`aR5900Branch.cpp`, write
cpuRegs.pc + link). PC update is in the generator; **delay-slot compilation +
block exit are 4.3** (not yet wired into the block compiler).
- [x] 4.1 Jumps: `J/JAL/JR/JALR` — codegen + wired into the block compiler (decoded
by `recEmitBranch`, delay slot compiled after, block exits to dispatcher).
- [~] 4.2 Conditional branches: `BEQ/BNE/BLEZ/BGTZ/BLTZ/BGEZ/BLTZAL/BGEZAL` —
**codegen done** (Cmp + Csel pc-select). "Likely" variants (BEQL/BNEL/BLEZL/
BGTZL/BLTZL/BGEZL/BLTZALL/BGEZALL — delay-slot nullification) still TODO.
- [ ] 4.3 **Dispatcher + delay-slot block compiler** (the runnable-rec core):
multi-instruction `recCompileBlock` loop w/ compile-time PC; compile branch +
delay slot + exit; C++ dispatcher loop in `recExecute` (pc→block→cpuEventTest);
interpreter fallback for unhandled opcodes; flip `Cpu = &recCpu`; BIOS-boot test.
- [ ] 4.4 Block linking (direct branch to already-compiled targets) + recLUT.
codegen + wired (`recEmitBranch`). "Likely" variants (BEQL/BNEL/BLEZL/BGTZL/
BLTZL/BGEZL/BLTZALL/BGEZALL — delay-slot nullification) run via interpreter
fallback for now; native codegen still TODO.
- [x] 4.3 **Dispatcher + delay-slot block compiler** — DONE & BIOS-boot verified.
Multi-instruction `recCompileBlock`; branch generator + delay slot + exit; C++
dispatcher loop in `recExecute` (pc→block→`_cpuEventTest_Shared`); per-opcode
interpreter fallback via `intExecuteOneInst`; `Cpu = &recCpu` on ARM64.
- [ ] 4.4 Block linking (direct branch to already-compiled targets) + recLUT
(replace the bring-up `s_blocks` unordered_map + recompile-on-miss).
- [ ] 4.5 Block invalidation on TLB-mapping change.
---
+29
View File
@@ -283,6 +283,35 @@ void intDoBranch(u32 target)
}
}
// Interpret exactly one guest instruction at cpuRegs.pc using the interpreter,
// then return. This is the recompiler's per-instruction fallback: the ARM64 EE
// rec dispatcher calls it for opcodes it cannot yet compile (likely branches,
// coprocessor ops, syscalls, traps, ...). It mirrors execI; for branch opcodes
// the interpreter's own branch functions handle the delay slot and PC redirect
// (and, when taken, flush the accrued cycle count via doBranch/intUpdateCPUCycles).
// For every non-branch op we flush the cycle count here so the rec's cpuRegs.cycle
// stays current. It must NOT do the interpreter's fastjmp exit (intJmpBuf is not
// set up in rec context); the rec drives exits through its own event test.
void intExecuteOneInst()
{
const u32 thispc = cpuRegs.pc;
// Pre-increment PC: exception handlers and branch target math expect cpuRegs.pc
// to already point at the delay slot (matches execI).
cpuRegs.pc += 4;
cpuRegs.code = memRead32(thispc);
const OPCODE& opcode = GetCurrentInstruction();
cpuBlockCycles += opcode.cycles * (2 - ((cpuRegs.CP0.n.Config >> 18) & 0x1));
opcode.interpret();
// Branch ops flush their own cycles inside doBranch when taken; everything else
// (including not-taken branches, which just fall through to the delay slot) we
// flush immediately so the dynarec's cycle/event accounting doesn't drift.
if (!(opcode.flags & IS_BRANCH))
intUpdateCPUCycles();
}
void intSetBranch()
{
branch2 = /*cpuRegs.branch =*/ 1;
+4
View File
@@ -290,6 +290,10 @@ void intSetBranch();
// parts of the Recs (namely COP0's branch codes and stuff).
void intDoBranch(u32 target);
// Interpret a single instruction at cpuRegs.pc (recompiler per-opcode fallback).
// See the implementation in Interpreter.cpp for the contract.
void intExecuteOneInst();
// modules loaded at hardcoded addresses by the kernel
const u32 EEKERNEL_START = 0;
const u32 EENULL_START = 0x81FC0;
+4 -1
View File
@@ -2733,7 +2733,10 @@ void VMManager::UpdateCPUImplementations()
CpuVU0 = EmuConfig.Cpu.Recompiler.EnableVU0 ? static_cast<BaseVUmicroCPU*>(&CpuMicroVU0) : static_cast<BaseVUmicroCPU*>(&CpuIntVU0);
CpuVU1 = EmuConfig.Cpu.Recompiler.EnableVU1 ? static_cast<BaseVUmicroCPU*>(&CpuMicroVU1) : static_cast<BaseVUmicroCPU*>(&CpuIntVU1);
#else
Cpu = &intCpu;
// ARM64 (Phase 4.3): the EE recompiler is now functional, so select it when the
// EE rec is enabled (it falls back to the interpreter per-opcode for anything it
// can't compile yet). IOP + VU remain interpreters until their ports land.
Cpu = CHECK_EEREC ? &recCpu : &intCpu;
psxCpu = &psxInt;
CpuVU0 = &CpuIntVU0;
+338 -33
View File
@@ -13,13 +13,21 @@
#include "arm64/aR5900.h"
#include "Config.h"
#include "Memory.h"
#include "R5900.h"
#include "R5900OpcodeTables.h"
#include "VMManager.h"
#include "common/Assertions.h"
#include "common/Console.h"
#include "common/FastJmp.h"
#include "common/Pcsx2Defs.h"
#include <unordered_map>
namespace a64 = vixl::aarch64;
// --------------------------------------------------------------------------------------
// EE code-cache layout (Phase 1.3)
// --------------------------------------------------------------------------------------
@@ -43,6 +51,33 @@ static u8* recPtrEnd = nullptr; // end of the code region / start of the constan
static ArmConstantPool s_const_pool;
// --------------------------------------------------------------------------------------
// Block cache + dispatcher state (Phase 4.3)
// --------------------------------------------------------------------------------------
// A compiled EE block: its host entry point and the (scaled) guest-cycle cost the
// dispatcher charges to cpuRegs.cycle after running it. This is the bring-up cache —
// a flat guest-PC -> block map, cleared wholesale on cache reset. The two-level
// recLUT + hardlinking optimisation is Phase 4.4.
struct EEBlock
{
u8* entry;
u32 cycles;
};
static std::unordered_map<u32, EEBlock> s_blocks;
// Hard cap on instructions per block, so straight-line code can't run the emit
// cursor away before we get a chance to reset. (x86 uses page/branch boundaries;
// this is a simpler bring-up bound.)
static constexpr u32 MAX_BLOCK_INSTS = 256;
// Execution / reset / exit plumbing, mirroring the x86 rec (iR5900.cpp).
static bool eeRecExecuting = false;
static bool eeRecNeedsReset = false;
static bool eeRecExitRequested = false;
static fastjmp_buf s_jmp_buf;
static void recResetRaw();
static void recReserve()
{
recPtr = SysMemory::GetEERec();
@@ -54,17 +89,35 @@ static void recReserve()
static void recShutdown()
{
s_const_pool.Destroy();
s_blocks.clear();
recPtr = nullptr;
recPtrEnd = nullptr;
}
static void recResetEE()
static void recResetRaw()
{
// Rewind the emit cursor and drop all cached trampolines/literals. Block map
// and dispatcher regeneration land in Phase 1.4; const-prop state in Phase 3.6.
// Rewind the emit cursor and drop the block cache + all cached trampolines/literals.
recPtr = SysMemory::GetEERec();
s_const_pool.Reset();
s_blocks.clear();
eeRecNeedsReset = false;
}
static void recResetEE()
{
if (eeRecExecuting)
{
// Can't safely rewind the code cache out from under a running block; defer
// the reset and bail out to the dispatcher loop at the next safe point.
eeRecNeedsReset = true;
eeRecExitRequested = true;
cpuRegs.nextEventCycle = 0; // force an event test promptly
return;
}
recResetRaw();
}
static void recStep()
@@ -216,55 +269,299 @@ static bool recTranslateOp(u32 op)
}
// --------------------------------------------------------------------------------------
// Minimal block compile loop (Phase 1.4, extended in 2.3)
// Branch / jump compilation (Phase 4.3)
// --------------------------------------------------------------------------------------
// Compile a single guest instruction at cpuRegs.pc into a block and return its
// entry point. This exercises the production emission lifecycle (armSetAsmPtr ->
// armStartBlock -> emit -> armEndBlock) against the real EE code cache, now with a
// real MIPS decode + dispatch for LW/SW instead of a fixed NOP body.
//
// This path is still INERT in normal operation: the interpreter stays the active
// Cpu provider (recExecute is never entered — Phase 4 adds the enter-trampoline
// that pins RESTATEPTR=&cpuRegs, the block LUT, PC/cycle management, and event
// tests). It is groundwork validated by compile-clean + the Arm64EmitEE unit tests
// that exercise the generators directly; full guest-memory round-trip validation
// is Phase 2.4.
static u8* recCompileBlock()
// Decode a control-flow opcode at branchpc and emit the matching Phase 4.1/4.2
// generator (which writes cpuRegs.pc and any link register). Returns true if a
// generator handled it. The compile-time target/fallthrough/link constants follow
// the interpreter's macros with _PC_ == branchpc + 4 (the delay-slot address):
// J/JAL target = (instr_index << 2) | ((branchpc + 4) & 0xF0000000)
// branch target = (branchpc + 4) + (s16(imm) << 2)
// fallthrough / link = branchpc + 8
// Likely branches, coprocessor branches, and traps return false (interpreter
// fallback handles them, including their delay-slot semantics).
static bool recEmitBranch(u32 op, u32 branchpc)
{
const u32 opcode = op >> 26;
const u32 rs = (op >> 21) & 0x1f;
const u32 rt = (op >> 16) & 0x1f;
const u32 rd = (op >> 11) & 0x1f;
const u32 funct = op & 0x3f;
const u32 delaypc = branchpc + 4;
const u32 jtarget = ((op & 0x03ffffff) << 2) | (delaypc & 0xf0000000u);
const u32 btarget = delaypc + (static_cast<u32>(static_cast<s32>(static_cast<s16>(op))) << 2);
const u32 fallthrough = branchpc + 8;
const u32 linkpc = branchpc + 8;
switch (opcode)
{
case 0x02: armEmitJ(jtarget); return true;
case 0x03: armEmitJAL(jtarget, linkpc); return true;
case 0x04: armEmitBEQ(rs, rt, btarget, fallthrough); return true;
case 0x05: armEmitBNE(rs, rt, btarget, fallthrough); return true;
case 0x06: armEmitBLEZ(rs, btarget, fallthrough); return true;
case 0x07: armEmitBGTZ(rs, btarget, fallthrough); return true;
case 0x00: // SPECIAL: JR / JALR
if (funct == 0x08) { armEmitJR(rs); return true; }
if (funct == 0x09) { armEmitJALR(rd, rs, linkpc); return true; }
return false;
case 0x01: // REGIMM: BLTZ / BGEZ / BLTZAL / BGEZAL (rt selector)
switch (rt)
{
case 0x00: armEmitBLTZ(rs, btarget, fallthrough); return true;
case 0x01: armEmitBGEZ(rs, btarget, fallthrough); return true;
case 0x10: armEmitBLTZAL(rs, btarget, fallthrough, linkpc); return true;
case 0x11: armEmitBGEZAL(rs, btarget, fallthrough, linkpc); return true;
default: return false; // likely (BLTZL/...) + traps
}
default: return false;
}
}
// Is this opcode a control-flow op we have a generator for? (Used to detect the
// block-terminating branch; everything else is either straight-line codegen or an
// interpreter fallback.)
static bool recIsHandledBranch(u32 op)
{
const u32 opcode = op >> 26;
const u32 funct = op & 0x3f;
const u32 rt = (op >> 16) & 0x1f;
switch (opcode)
{
case 0x02: case 0x03: case 0x04: case 0x05: case 0x06: case 0x07:
return true;
case 0x00:
return funct == 0x08 || funct == 0x09;
case 0x01:
return rt == 0x00 || rt == 0x01 || rt == 0x10 || rt == 0x11;
default:
return false;
}
}
// Emit cpuRegs.code = op, then call the interpreter's handler for `op`. Used for a
// delay-slot instruction the straight-line generators can't handle. Does NOT touch
// cpuRegs.pc (the branch generator already committed the next PC, and a normal
// delay-slot op never writes PC). RESTATEPTR(x19) is callee-saved across the call.
static void recEmitInterpInline(u32 op)
{
armAsm->Mov(RSCRATCHADDR.W(), op);
armAsm->Str(RSCRATCHADDR.W(), a64::MemOperand(RESTATEPTR, EE_CODE_OFFSET));
armEmitCall(reinterpret_cast<const void*>(R5900::GetInstruction(op).interpret));
}
// Compile one straight-line or delay-slot instruction: real generator if we have
// one, otherwise an inline interpreter call.
static void recEmitOp(u32 op)
{
if (!recTranslateOp(op))
recEmitInterpInline(op);
}
// cpuRegs.pc = imm (block fallthrough / early-exit target).
static void recEmitWritePc(u32 pc)
{
armAsm->Mov(RSCRATCHADDR.W(), pc);
armAsm->Str(RSCRATCHADDR.W(), a64::MemOperand(RESTATEPTR, EE_PC_OFFSET));
}
// EE cycle scaling — mirrors iR5900.cpp scaleblockcycles_calculation() so block
// timing matches the x86 rec / interpreter for a given EECycleRate.
static u32 recScaleBlockCycles(u32 raw)
{
const bool lowcycles = (raw <= 40);
const s8 cyclerate = EmuConfig.Speedhacks.EECycleRate;
u32 scale_cycles;
if (cyclerate == 0 || lowcycles || cyclerate < -99 || cyclerate > 3)
scale_cycles = raw >> 3;
else if (cyclerate > 1)
scale_cycles = raw >> (2 + cyclerate);
else if (cyclerate == 1)
scale_cycles = static_cast<u32>((raw >> 3) / 1.3f);
else if (cyclerate == -1)
scale_cycles = (raw <= 80 || raw > 168 ? 5 : 7) * raw / 32;
else
scale_cycles = ((5 + (-2 * (cyclerate + 1))) * raw) >> 5;
return (scale_cycles < 1) ? 1 : scale_cycles;
}
// --------------------------------------------------------------------------------------
// Block compiler (Phase 4.3)
// --------------------------------------------------------------------------------------
// Compile a straight-line run starting at startpc into one host block:
// - straight-line ops we can codegen are emitted inline;
// - the run stops at the first control-flow op we have a generator for — that
// branch + its delay slot are compiled and the block ends (the branch generator
// wrote cpuRegs.pc);
// - if an op we cannot start a block with is hit first, returns nullptr so the
// dispatcher interprets it instead;
// - otherwise the block ends at the next un-compilable op (or the length cap),
// writing cpuRegs.pc to that address so the dispatcher resumes there.
// On success fills *out_cycles with the scaled guest-cycle cost. The host block is
// self-contained: prologue establishes RESTATEPTR(x19) via the enter trampoline (it
// is set by the caller of the block), and the epilogue is just RET.
static u8* recCompileBlock(u32 startpc, u32* out_cycles)
{
// Whole-cache reset once we run past the code region into the constant-pool tail.
if (recPtr >= recPtrEnd)
recResetEE();
recResetRaw();
armSetAsmPtr(recPtr, recPtrEnd - recPtr, &s_const_pool);
u8* const entry = armStartBlock();
const u32 op = memRead32(cpuRegs.pc);
cpuRegs.code = op;
if (!recTranslateOp(op))
armAsm->Nop(); // unhandled opcode placeholder (most ops, until later phases)
// Block prologue: preserve the caller's RESTATEPTR(x19) + LR (the body's vtlb /
// interpreter calls clobber LR), and point RESTATEPTR at the guest cpuRegs file.
// Matches the unit-test harness (RunEEGen). sp stays 16-byte aligned.
armAsm->Stp(RESTATEPTR, a64::x30, a64::MemOperand(a64::sp, -16, a64::PreIndex));
armMoveAddressToReg(RESTATEPTR, &cpuRegs);
u32 pc = startpc;
u32 raw_cycles = 0;
u32 compiled = 0;
bool ok = true;
for (;;)
{
const u32 op = memRead32(pc);
const R5900::OPCODE& info = R5900::GetInstruction(op);
if (recIsHandledBranch(op))
{
// Terminate the block: branch generator + delay slot + exit.
raw_cycles += info.cycles;
recEmitBranch(op, pc); // writes cpuRegs.pc (taken/fallthrough/link)
const u32 delay_op = memRead32(pc + 4);
raw_cycles += R5900::GetInstruction(delay_op).cycles;
recEmitOp(delay_op); // delay slot — must not write cpuRegs.pc
break;
}
// Straight-line op we can codegen? (Generators decode from `op` directly;
// they never read cpuRegs.code, so nothing to set here at compile time.)
if (recTranslateOp(op))
{
raw_cycles += info.cycles;
pc += 4;
if (++compiled >= MAX_BLOCK_INSTS)
{
recEmitWritePc(pc); // resume at the next instruction
break;
}
continue;
}
// Un-compilable, non-branch op (FPU/COP/MMI/syscall/likely-branch/...).
if (compiled == 0)
{
// Nothing emitted yet — let the dispatcher interpret this single op.
ok = false;
break;
}
// End the block here; the dispatcher will interpret this op next.
recEmitWritePc(pc);
break;
}
if (!ok)
{
// First op isn't compilable — discard this (never-executed) block without
// advancing recPtr, so the space is reused. Close the assembler cleanly; the
// dispatcher will interpret the op instead.
armAsm->Ldp(RESTATEPTR, a64::x30, a64::MemOperand(a64::sp, 16, a64::PostIndex));
armAsm->Ret();
armEndBlock();
return nullptr;
}
// Block epilogue: restore the caller's RESTATEPTR(x19) + LR, then return.
armAsm->Ldp(RESTATEPTR, a64::x30, a64::MemOperand(a64::sp, 16, a64::PostIndex));
armAsm->Ret();
recPtr = armEndBlock();
*out_cycles = recScaleBlockCycles(raw_cycles);
return entry;
}
// Look up (or compile) the block at `pc`. Returns nullptr if `pc` starts on an op
// the rec can't compile (the caller should interpret one instruction instead).
static const EEBlock* recGetBlock(u32 pc)
{
const auto it = s_blocks.find(pc);
if (it != s_blocks.end())
return &it->second;
u32 cycles = 0;
u8* const entry = recCompileBlock(pc, &cycles);
if (!entry)
return nullptr;
const auto ins = s_blocks.emplace(pc, EEBlock{entry, cycles});
return &ins.first->second;
}
static void recEventTest()
{
_cpuEventTest_Shared();
if (eeRecExitRequested)
{
eeRecExitRequested = false;
fastjmp_jmp(&s_jmp_buf, 1);
}
}
// The dispatcher loop. Reads cpuRegs.pc, runs (compiling if needed) the block there
// — or interprets one instruction when the block starts on an un-compilable op —
// then charges cycles and runs the EE event test. Exits via fastjmp on request.
static void recExecute()
{
// Phase 1.4 proof-of-life: compile one trivial block through the real emitter,
// enter it, and return. There is no execution loop / dispatcher yet, so this
// runs exactly one (empty) block. The interpreter remains the active Cpu
// provider (recCpu is not yet selected in VMManager — Phase 1.5), so this path
// is not hit in normal operation; it exists to validate emit + enter + return
// end-to-end on the EE code cache before real codegen lands.
u8* const entry = recCompileBlock();
reinterpret_cast<void (*)()>(entry)();
if (eeRecNeedsReset)
recResetRaw();
if (fastjmp_set(&s_jmp_buf) != 0)
{
eeRecExecuting = false;
return;
}
eeRecExecuting = true;
for (;;)
{
const EEBlock* const block = recGetBlock(cpuRegs.pc);
if (block)
{
// The block establishes RESTATEPTR(x19) itself and returns via its saved
// LR, so it can be entered with a plain indirect call.
reinterpret_cast<void (*)()>(block->entry)();
cpuRegs.cycle += block->cycles;
}
else
{
// Block starts on an op we can't compile yet — interpret exactly one
// instruction (it handles its own delay slot / PC / cycles).
intExecuteOneInst();
}
if (static_cast<s32>(cpuRegs.cycle - cpuRegs.nextEventCycle) >= 0)
recEventTest();
}
}
static void recSafeExitExecution()
{
// TODO(Phase 4): signal the dispatcher loop to exit at a safe point.
// Ask the dispatcher loop to fastjmp out at the next event test. Forcing the
// event cycle to 0 guarantees the test fires after the current block.
eeRecExitRequested = true;
cpuRegs.nextEventCycle = 0;
}
static void recCancelInstruction()
@@ -274,7 +571,15 @@ static void recCancelInstruction()
static void recClear(u32 addr, u32 size)
{
// TODO(Phase 4.5): invalidate compiled blocks covering [addr, addr+size).
// Bring-up: any code-cache clear (TLB remap, manual invalidation) drops the
// whole block cache. Targeted invalidation + the recLUT land in Phase 4.4/4.5.
if (eeRecExecuting)
{
eeRecNeedsReset = true;
recSafeExitExecution();
return;
}
recResetRaw();
}
R5900cpu recCpu = {
+3
View File
@@ -80,6 +80,9 @@ static constexpr u32 EE_GPR_OFFSET(u32 n) { return n * 16u; }
// Byte offset of cpuRegs.pc (the next-PC field the branch/jump generators write).
static constexpr u32 EE_PC_OFFSET = static_cast<u32>(offsetof(cpuRegisters, pc));
// Byte offset of cpuRegs.code (the current-instruction field the interpreter reads).
static constexpr u32 EE_CODE_OFFSET = static_cast<u32>(offsetof(cpuRegisters, code));
void armEmitEffectiveAddr(const vixl::aarch64::Register& dst, u32 rs, s32 imm);
void armEmitLoadGpr(u32 bits, bool sign, u32 rt, u32 rs, s32 imm);
void armEmitStoreGpr(u32 bits, u32 rt, u32 rs, s32 imm);