From c308ab1b991579f29f65a2b6bc872d03696d59d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 00:56:06 +0200 Subject: [PATCH 01/14] Headless: Decode vertices with the JIT where the host can, like the app Game runs in headless measured the C++ vertex decoder, which the app only uses where it can't JIT. pspautotests and the frametests pass with the JIT decoders. --vertex-jit=false brings back the C++ decoder. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/CmdLine.cpp | 1 + Core/CmdLine.h | 1 + headless/Headless.cpp | 5 ++--- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Core/CmdLine.cpp b/Core/CmdLine.cpp index de55ed159f..96f8daf639 100644 --- a/Core/CmdLine.cpp +++ b/Core/CmdLine.cpp @@ -208,6 +208,7 @@ static const CommandLineParam g_autoParams[] = { {POFF(replayEnd), CmdParamType::Int, "replay-end", '\0', "When replaying a GE frame dump, only draw primitives up to this one (1-based)", CmdLineMode::Headless}, {POFF(dumpCompat), CmdParamType::Bool, "dump-compat", '\0', "Apply the game's compat.ini settings when replaying a GE frame dump (off by default)", CmdLineMode::Headless}, {POFF(hardwareTransform), CmdParamType::Bool, "hardware-transform", '\0', "Transform vertices on the GPU (default true); false transforms them on the CPU", CmdLineMode::Headless}, + {POFF(vertexJit), CmdParamType::Bool, "vertex-jit", '\0', "Decode vertices with the JIT where the host can (default; =false for the C++ decoder)", CmdLineMode::Headless}, {POFF(disableGPUFeatures), CmdParamType::String, "disable-gpu-features", '\0', "Act as if the GPU lacked these, to test the fallbacks: comma-separated clip-distance, cull-distance, depth-clamp, framebuffer-fetch, dual-source-blend", CmdLineMode::Headless}, {POFF(depthFilenameSave), CmdParamType::String, "depth-save", '\0', "Save the depth buffer to FILE along with the screenshot (u32 width, u32 height, u16 depths; float depths if FILE ends in .f32)", CmdLineMode::Headless}, {POFF(screenshotRenderTarget), CmdParamType::Bool, "screenshot-render-target", '\0', "Take screenshots of the current render target instead of the displayed framebuffer", CmdLineMode::Headless}, diff --git a/Core/CmdLine.h b/Core/CmdLine.h index 66a0f9e9b4..554fc2160c 100644 --- a/Core/CmdLine.h +++ b/Core/CmdLine.h @@ -195,6 +195,7 @@ struct CommandLineOptions { // Headless: apply the game's compatibility settings when replaying a GE frame dump. std::optional dumpCompat; std::optional hardwareTransform; + std::optional vertexJit; // Headless: comma-separated clip-distance, cull-distance, depth-clamp, framebuffer-fetch, dual-source-blend. std::optional disableGPUFeatures; std::optional screenshotRenderTarget; diff --git a/headless/Headless.cpp b/headless/Headless.cpp index 9d13985a7d..089545a6a3 100644 --- a/headless/Headless.cpp +++ b/headless/Headless.cpp @@ -1117,9 +1117,8 @@ int main(int argc, const char* argv[]) { // but not now. CoreParameter coreParameter; coreParameter.cpuCore = (CPUCore)cpuCore; - // The pspautotests expectations and frametest references were recorded with the C++ vertex - // decoder, and the JIT decoders don't match it everywhere yet. - coreParameter.bUseVertexDecoderJit = false; + // Like the app, decode vertices with the JIT where the host can run one. + coreParameter.bUseVertexDecoderJit = cmdLineOptions.vertexJit.value_or(System_GetPropertyBool(SYSPROP_CAN_JIT)); coreParameter.gpuCore = (GPUCore)gpuCore; coreParameter.graphicsContext = graphicsContext; coreParameter.enableSound = false; From 73f1b9465ab31a5cc0e5030fcb40792f4df255ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 01:55:40 +0200 Subject: [PATCH 02/14] Interpreter: Vector loads ignore the D prefix's write mask As on hardware (cpu/vfpu/prefix_load), lv.q, lvl.q and lvr.q write every lane whatever mask a pending D prefix has, and leave the prefix pending, like lv.s and mtv already did. The IR cores fall back to the interpreter for lvl.q and lvr.q, so they get this too. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/InterpreterVFPU.cpp | 17 +++++++++++++---- pspautotests | 2 +- test.py | 1 + 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Core/MIPS/InterpreterVFPU.cpp b/Core/MIPS/InterpreterVFPU.cpp index 838025d1be..716d7838f6 100644 --- a/Core/MIPS/InterpreterVFPU.cpp +++ b/Core/MIPS/InterpreterVFPU.cpp @@ -236,6 +236,15 @@ namespace MIPSInt PC += 4; } + // The loads write every lane, whatever mask a pending D prefix has, and leave the prefix pending + // (cpu/vfpu/prefix_load). + static void WriteLoadedVector(MIPSState *mips, const float *d, int vt) { + const u32 dprefix = mips->vfpuCtrl[VFPU_CTRL_DPREFIX]; + mips->vfpuCtrl[VFPU_CTRL_DPREFIX] = dprefix & ~0xF00; + WriteVector(mips, d, V_Quad, vt); + mips->vfpuCtrl[VFPU_CTRL_DPREFIX] = dprefix; + } + void Int_SVQ(MIPSState *mips, MIPSOpcode op) { int imm = SignExtend16ToS32(op & 0xFFFC); int rs = _RS; @@ -269,7 +278,7 @@ namespace MIPSInt d[i] = valid ? Memory::ReadUnchecked_Float(addr + 4 * i) : 0.0f; } } - WriteVector(mips, d, V_Quad, vt); + WriteLoadedVector(mips, d, vt); } break; @@ -279,13 +288,13 @@ namespace MIPSInt if ((addr & 0xF) || !Memory::IsValid4AlignedAddress(addr)) { Core_MemoryException(addr, 16, PC, MemoryExceptionType::READ_WORD, "lv.q"); const float zero[4]{}; - WriteVector(mips, zero, V_Quad, vt); + WriteLoadedVector(mips, zero, vt); break; } #ifndef COMMON_BIG_ENDIAN cf = reinterpret_cast(Memory::GetPointerUnchecked(addr)); - WriteVector(mips, cf, V_Quad, vt); + WriteLoadedVector(mips, cf, vt); #else float lvqd[4]; @@ -294,7 +303,7 @@ namespace MIPSInt lvqd[2] = Memory::ReadUnchecked_Float(addr + 8); lvqd[3] = Memory::ReadUnchecked_Float(addr + 12); - WriteVector(mips, lvqd, V_Quad, vt); + WriteLoadedVector(mips, lvqd, vt); #endif break; diff --git a/pspautotests b/pspautotests index 011a777e66..3e011268b6 160000 --- a/pspautotests +++ b/pspautotests @@ -1 +1 @@ -Subproject commit 011a777e6623632ce7dca581782fb0f78cca94f8 +Subproject commit 3e011268b66418d9febf989a6e8467ba3d0042df diff --git a/test.py b/test.py index cbf913ff8d..2affad5e24 100755 --- a/test.py +++ b/test.py @@ -101,6 +101,7 @@ tests_good = [ "cpu/vfpu/prefix_branch", "cpu/vfpu/prefix_ctrl", "cpu/vfpu/prefix_lanes", + "cpu/vfpu/prefix_load", "cpu/vfpu/vbranch", "cpu/vfpu/vrnd", "cpu/vfpu/overlap", From a1b845d05115b3330c064bdb2ab5f815c75edfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:09:08 +0200 Subject: [PATCH 03/14] IR: Fix vmscl of two transposed matrices with an offset Both were turned into their untransposed forms to get Vec4s, but for a matrix whose offset makes it wrap around, flipping the transposed bit names other registers. Taking the elements of both in transposed order gets the same Vec4s without that. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 016a1ebc81..97ddfa1788 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -1637,18 +1637,32 @@ namespace MIPSComp { MatrixSize sz = GetMtxSize(op); int n = GetMatrixSide(sz); - // The entire matrix is scaled equally, so transpose doesn't matter. Let's normalize. - // (A 1x1 matrix has no transposed form, the bit then picks a different register.) - if (n > 1 && IsMatrixTransposed(vs) && IsMatrixTransposed(vd)) { - vs = TransposeMatrixReg(vs); - vd = TransposeMatrixReg(vd); - } - - u8 sregs[16], dregs[16], tregs[1]; + u8 sregs[16]{}, dregs[16]{}, tregs[1]; GetMatrixRegs(sregs, sz, vs); GetMatrixRegs(dregs, sz, vd); GetVectorRegs(tregs, V_Single, vt); + // Every element is scaled the same, so the elements can go in any order, as long as each + // source keeps its destination. Transposed matrices come out as Vec4s when both are taken + // in transposed order. (Flipping the transposed bit of the registers instead would name other + // registers for a matrix with an offset, which wraps around.) + if (!IsMatrixVec4(sz, sregs) || !IsMatrixVec4(sz, dregs)) { + u8 st[16], dt[16]; + for (int k = 0; k < 16; k++) { + st[k] = sregs[(k % 4) * 4 + k / 4]; + dt[k] = dregs[(k % 4) * 4 + k / 4]; + } + if (IsMatrixVec4(sz, st) && IsMatrixVec4(sz, dt)) { + memcpy(sregs, st, sizeof(st)); + memcpy(dregs, dt, sizeof(dt)); + } + } + + // Either way, a source that partly overlaps the destination would be read after it's written. + if (vs != vd && GetMatrixOverlap(vs, vd, sz) != OVERLAP_NONE) { + DISABLE; + } + if (GetMtx(vt) != GetMtx(vd) && IsMatrixVec4(sz, sregs) && IsMatrixVec4(sz, dregs)) { for (int i = 0; i < n; ++i) { ir.Write(IROp::Vec4Scale, dregs[i * 4], sregs[i * 4], tregs[0]); @@ -1656,11 +1670,7 @@ namespace MIPSComp { return; } - // Element by element otherwise, which works for any size and transposition. A source that - // partly overlaps the destination would be read after it's written, though. - if (vs != vd && GetMatrixOverlap(vs, vd, sz) != OVERLAP_NONE) { - DISABLE; - } + // Element by element otherwise, which works for any size and transposition. // The scale can be part of the destination, so keep a copy. ir.Write(IROp::FMov, IRVTEMP_0, tregs[0]); for (int a = 0; a < n; a++) { From 480ebbe601a156154d38841ce41bd37d70e168f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:09:08 +0200 Subject: [PATCH 04/14] VFPU: vfim converts its half float like vh2f As on hardware (cpu/vfpu/vfim), a denormal half becomes a signed zero, and inf and NaN keep their mantissa bits in place. The interpreter got denormals half their value, the IR the IEEE one. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 7 +++---- Core/MIPS/InterpreterVFPU.cpp | 2 +- Core/MIPS/MIPSVFPUUtils.cpp | 4 ++++ Core/MIPS/MIPSVFPUUtils.h | 2 ++ pspautotests | 2 +- test.py | 1 + 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 97ddfa1788..24fce93ab9 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -2503,13 +2503,12 @@ namespace MIPSComp { // Vector half-float immediate // d[0] = float(imm) - FP16 half; - half.u = op & 0xFFFF; - FP32 fval = half_to_float_fast5(half); + // Converted like vh2f: denormal halves are zero (cpu/vfpu/vfim). + const float value = vfpu_h2f_float((u16)(op & 0xFFFF)); u8 dreg; GetVectorRegsPrefixD(&dreg, V_Single, _VT); - ir.WriteSetConstantFloat(dreg, fval.f); + ir.WriteSetConstantFloat(dreg, value); ApplyPrefixD(&dreg, V_Single, _VT); } diff --git a/Core/MIPS/InterpreterVFPU.cpp b/Core/MIPS/InterpreterVFPU.cpp index 716d7838f6..a30d66c2af 100644 --- a/Core/MIPS/InterpreterVFPU.cpp +++ b/Core/MIPS/InterpreterVFPU.cpp @@ -474,7 +474,7 @@ namespace MIPSInt if (type == 6) { f[0] = (float)imm; // viim } else if (type == 7) { - f[0] = Float16ToFloat32((u16)uimm16); // vfim + f[0] = vfpu_h2f_float((u16)uimm16); // vfim, converted like vh2f (cpu/vfpu/vfim) } else { _dbg_assert_msg_( 0, "Invalid Viim opcode type %d", type); f[0] = 0; diff --git a/Core/MIPS/MIPSVFPUUtils.cpp b/Core/MIPS/MIPSVFPUUtils.cpp index a40644bd3f..8fcab246cb 100644 --- a/Core/MIPS/MIPSVFPUUtils.cpp +++ b/Core/MIPS/MIPSVFPUUtils.cpp @@ -680,6 +680,10 @@ u32 vfpu_h2f(u16 h) { return sign | ((exp + 112) << 23) | (mant << 13); } +float vfpu_h2f_float(u16 h) { + return vfpu_float_from_bits(vfpu_h2f(h)); +} + float vfpu_h2f_lower(float word) { return vfpu_float_from_bits(vfpu_h2f((u16)(vfpu_bits_from_float(word) & 0xFFFF))); } diff --git a/Core/MIPS/MIPSVFPUUtils.h b/Core/MIPS/MIPSVFPUUtils.h index c96a87299b..fc2a44b72f 100644 --- a/Core/MIPS/MIPSVFPUUtils.h +++ b/Core/MIPS/MIPSVFPUUtils.h @@ -311,6 +311,8 @@ float Float16ToFloat32(unsigned short l); // flushes below 2^-14 to zero and keeps the low ten mantissa bits of a NaN; vh2f flushes // subnormal halves and keeps inf/NaN mantissa bits unshifted. u32 vfpu_h2f(u16 h); +// The same, as a float (vfim). +float vfpu_h2f_float(u16 h); // vh2f of the lower or upper half of a word, with the word and result as float bits. For the JITs. float vfpu_h2f_lower(float word); float vfpu_h2f_upper(float word); diff --git a/pspautotests b/pspautotests index 3e011268b6..13554e4f6d 160000 --- a/pspautotests +++ b/pspautotests @@ -1 +1 @@ -Subproject commit 3e011268b66418d9febf989a6e8467ba3d0042df +Subproject commit 13554e4f6dc7b3aa0e61327f37f3121e5df9738f diff --git a/test.py b/test.py index 2affad5e24..354adb33ea 100755 --- a/test.py +++ b/test.py @@ -102,6 +102,7 @@ tests_good = [ "cpu/vfpu/prefix_ctrl", "cpu/vfpu/prefix_lanes", "cpu/vfpu/prefix_load", + "cpu/vfpu/vfim", "cpu/vfpu/vbranch", "cpu/vfpu/vrnd", "cpu/vfpu/overlap", From 8ca487910eae52dcdfe74ee097365d526d4b2423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:13:23 +0200 Subject: [PATCH 05/14] IR: vc2i and vuc2i read their source at the size encoded They read one lane, the first of the source vector at the size in the instruction, as the interpreter does, which for the pair and quad encodings is another register than the single the IR took. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 24fce93ab9..5aa94ebf58 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -2193,9 +2193,15 @@ namespace MIPSComp { VectorSize sz = GetVecSize(op); VectorSize outsize; + // vc2i and vuc2i read one lane, the first of the source at the size encoded, which decides + // which register that is. + int sourceReg = -1; if (bits == 8) { outsize = V_Quad; - sz = V_Single; // For some reason, sz is set to Quad in this case though the outsize is Single. + u8 full[4]; + GetVectorRegs(full, sz, _VS); + sourceReg = full[0]; + sz = V_Single; } else { switch (sz) { case V_Single: @@ -2211,6 +2217,8 @@ namespace MIPSComp { u8 sregs[2], dregs[4], tempregs[4], srcregs[2]; GetVectorRegsPrefixS(sregs, sz, _VS); + if (sourceReg >= 0) + sregs[0] = sourceReg; GetVectorRegsPrefixD(dregs, outsize, _VD); memcpy(tempregs, dregs, sizeof(dregs)); memcpy(srcregs, sregs, sizeof(sregs)); From 289c65d7a2eb0499adc2368b9d990ebf0ed351a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:13:23 +0200 Subject: [PATCH 06/14] IR: Don't propagate an FP copy into a result that covers its source OptimizeFPMoves replaced a temp read by vs2i (from a copy of a lane of its own destination) with the original register, which the ARM64 JIT then lost by mapping the destination without loading it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRPassSimplify.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Core/MIPS/IR/IRPassSimplify.cpp b/Core/MIPS/IR/IRPassSimplify.cpp index 699cc729ad..8df29e456c 100644 --- a/Core/MIPS/IR/IRPassSimplify.cpp +++ b/Core/MIPS/IR/IRPassSimplify.cpp @@ -1157,6 +1157,12 @@ bool PurgeTemps(const IRWriter &in, IRWriter &out, const IROptions &opts) { checkMismatch(inst.src3, inst.m.types[0]); bool cannotReplace = !readsDirectly || lenMismatch; + // Nor where a multi-lane result would cover the source it reads instead, which the + // backends don't expect (the copy was what kept them apart). + IRReg destRegs[4]; + const int numDest = IRDestFPRs(inst, destRegs); + if (numDest > 1 && check.srcReg >= destRegs[0] + 32 && check.srcReg < destRegs[0] + 32 + numDest) + cannotReplace = true; if (!cannotReplace && check.srcReg >= 32 && lastWrittenTo[check.srcReg] < check.index) { // This is probably not worth doing unless we can get rid of a temp. if (!check.readByExit) { From cc2fd7dcbb01e5d82329114ff4273375c2177ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:15:33 +0200 Subject: [PATCH 07/14] IR: Fix the matrix inits of transposed matrices with an offset Like vmscl, they flipped the transposed bit to get Vec4s, which names other registers for a matrix that wraps around. Taking the elements in transposed order does it without. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 5aa94ebf58..ba2cd4721d 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -688,14 +688,18 @@ namespace MIPSComp { // d[N,M] = CONST[N,M] int vd = _VD; - // (A 1x1 matrix has no transposed form, the bit then picks a different register.) - if (sz != M_1x1 && IsMatrixTransposed(vd)) { - // All outputs are transpositionally symmetric, so should be fine. - vd = TransposeMatrixReg(vd); - } - - u8 dregs[16]; + u8 dregs[16]{}; GetMatrixRegs(dregs, sz, vd); + // The constants are symmetric, so a transposed matrix comes out as Vec4s taken in transposed + // order. (Flipping the transposed bit instead names other registers for a matrix with an + // offset, which wraps around.) + if (!IsMatrixVec4(sz, dregs)) { + u8 dt[16]; + for (int k = 0; k < 16; k++) + dt[k] = dregs[(k % 4) * 4 + k / 4]; + if (IsMatrixVec4(sz, dt)) + memcpy(dregs, dt, sizeof(dt)); + } if (!IsMatrixVec4(sz, dregs)) { // 3x3 is decently common. It expands a lot, but let's set each. // TODO: It might be worth using Vec4Blend for 3x3 to mask w. @@ -729,14 +733,8 @@ namespace MIPSComp { return; } - // Not really about trying here, it will work if enabled. - VectorSize vsz = GetVectorSize(sz); - u8 vecs[4]; - GetMatrixColumns(vd, sz, vecs); + // Each Vec4 of dregs in turn (lane i of the i-th is the diagonal). for (int i = 0; i < 4; i++) { - u8 vec[4]; - GetVectorRegs(vec, vsz, vecs[i]); - // As they are columns, they will be nicely consecutive. Vec4Init init; switch ((op >> 16) & 0xF) { case 3: @@ -752,7 +750,7 @@ namespace MIPSComp { INVALIDOP; return; } - ir.Write(IROp::Vec4Init, vec[0], (int)init); + ir.Write(IROp::Vec4Init, dregs[i * 4], (int)init); } } From 130a637511de2ce12947977ffc7bab53237bf181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:16:52 +0200 Subject: [PATCH 08/14] IR: vf2i and vi2f go through temps when the result overlaps the source They wrote each lane straight to the destination, so with a destination that holds a later lane's source (a wrapped row over a column, say) that lane read the converted value. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index ba2cd4721d..4970463589 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -1330,16 +1330,22 @@ namespace MIPSComp { uint8_t imm = (op >> 16) & 0x1f; - u8 sregs[4], dregs[4]; + u8 sregs[4], dregs[4], outregs[4]; GetVectorRegsPrefixS(sregs, sz, _VS); GetVectorRegsPrefixD(dregs, sz, _VD); + // Through temps if a lane would be written before a later one reads it. + const bool direct = IsOverlapSafeAllowS(n, dregs, n, sregs); + for (int i = 0; i < n; i++) + outregs[i] = direct ? dregs[i] : IRVTEMP_0 + i; for (int i = 0; i < n; i++) { if (imm == 0) - ir.Write(IROp::FCvtSW, dregs[i], sregs[i]); + ir.Write(IROp::FCvtSW, outregs[i], sregs[i]); else - ir.Write(IROp::FCvtScaledSW, dregs[i], sregs[i], imm); + ir.Write(IROp::FCvtScaledSW, outregs[i], sregs[i], imm); } + for (int i = 0; i < n && !direct; i++) + ir.Write(IROp::FMov, dregs[i], outregs[i]); ApplyPrefixD(dregs, sz, _VD); } @@ -1386,9 +1392,13 @@ namespace MIPSComp { uint8_t imm = (op >> 16) & 0x1f; - u8 sregs[4], dregs[4]; + u8 sregs[4], dregs[4], outregs[4]; GetVectorRegsPrefixS(sregs, sz, _VS); GetVectorRegsPrefixD(dregs, sz, _VD); + // Through temps if a lane would be written before a later one reads it. + const bool direct = IsOverlapSafeAllowS(n, dregs, n, sregs); + for (int i = 0; i < n; i++) + outregs[i] = direct ? dregs[i] : IRVTEMP_0 + i; // Same values as FCR31. uint8_t rmode = (op >> 21) & 3; @@ -1397,24 +1407,24 @@ namespace MIPSComp { if (imm != 0) { for (int i = 0; i < n; i++) - ir.Write(IROp::FCvtScaledWS, dregs[i], sregs[i], imm | (rmode << 6)); + ir.Write(IROp::FCvtScaledWS, outregs[i], sregs[i], imm | (rmode << 6)); } else { for (int i = 0; i < n; i++) { switch (IRRoundMode(rmode)) { case IRRoundMode::RINT_0: // vf2in - ir.Write(IROp::FRound, dregs[i], sregs[i]); + ir.Write(IROp::FRound, outregs[i], sregs[i]); break; case IRRoundMode::CAST_1: // vf2iz - ir.Write(IROp::FTrunc, dregs[i], sregs[i]); + ir.Write(IROp::FTrunc, outregs[i], sregs[i]); break; case IRRoundMode::CEIL_2: // vf2iu - ir.Write(IROp::FCeil, dregs[i], sregs[i]); + ir.Write(IROp::FCeil, outregs[i], sregs[i]); break; case IRRoundMode::FLOOR_3: // vf2id - ir.Write(IROp::FFloor, dregs[i], sregs[i]); + ir.Write(IROp::FFloor, outregs[i], sregs[i]); break; default: @@ -1422,6 +1432,8 @@ namespace MIPSComp { } } } + for (int i = 0; i < n && !direct; i++) + ir.Write(IROp::FMov, dregs[i], outregs[i]); ApplyPrefixDMask(dregs, sz, _VD); } From 91a0ac7567756bc4885095295e6c7f07e08e8210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:33:18 +0200 Subject: [PATCH 09/14] IR JITs: Flushing a host register that holds zero unmaps it FlushNativeReg left a register caching MIPS zero mapped, as there's nothing to store, but its callers flush to clobber the register. After the dot product routine's call, a store of zero stored its result. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRRegCache.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Core/MIPS/IR/IRRegCache.cpp b/Core/MIPS/IR/IRRegCache.cpp index b8ce57a9b5..c88f6a23f1 100644 --- a/Core/MIPS/IR/IRRegCache.cpp +++ b/Core/MIPS/IR/IRRegCache.cpp @@ -576,11 +576,17 @@ void IRNativeRegCacheBase::DiscardNativeReg(IRNativeReg nreg) { void IRNativeRegCacheBase::FlushNativeReg(IRNativeReg nreg) { _assert_msg_(nreg >= 0 && nreg < config_.totalNativeRegs, "FlushNativeReg on invalid register %d", nreg); - if (nr[nreg].mipsReg == IRREG_INVALID || nr[nreg].mipsReg == MIPS_REG_ZERO) { - // Nothing to do, reg not mapped or mapped to fixed zero. + if (nr[nreg].mipsReg == IRREG_INVALID) { + // Nothing to do, reg not mapped. _dbg_assert_(!nr[nreg].isDirty); return; } + if (nr[nreg].mipsReg == MIPS_REG_ZERO) { + // Nothing to store, but whoever flushes is about to clobber it. + _dbg_assert_(!nr[nreg].isDirty); + DiscardNativeReg(nreg); + return; + } _dbg_assert_(!mr[nr[nreg].mipsReg].isStatic); if (mr[nr[nreg].mipsReg].isStatic) { ERROR_LOG(Log::JIT, "Cannot FlushNativeReg a statically mapped register"); From be09c1b25b0a78188f11d469b917bccea1c8c26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:33:18 +0200 Subject: [PATCH 10/14] IR: Compare store offsets as signed in OptimizeLoadsAfterStores A store at a negative offset whose end wraps past zero, like 16 bytes at -16, counted as ending before a load at -12, so the load reused an older value from before the store. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRPassSimplify.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Core/MIPS/IR/IRPassSimplify.cpp b/Core/MIPS/IR/IRPassSimplify.cpp index 8df29e456c..6a6fc79147 100644 --- a/Core/MIPS/IR/IRPassSimplify.cpp +++ b/Core/MIPS/IR/IRPassSimplify.cpp @@ -2328,7 +2328,9 @@ bool OptimizeLoadsAfterStores(const IRWriter &in, IRWriter &out, const IROptions known.erase(std::remove_if(known.begin(), known.end(), [&](const Known &k) { if (k.base != inst.src1 || !plainStore) return true; - return !(k.offset + k.size <= inst.constant || inst.constant + info.size <= k.offset); + // Signed, as the offsets are: -16 + 16 mustn't wrap to below -12. + const s64 known = (s32)k.offset, store = (s32)inst.constant; + return !(known + k.size <= store || store + info.size <= known); }), known.end()); } From 8c3f929615cc9a0cb0d545c2657cddfe2a93e3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:33:18 +0200 Subject: [PATCH 11/14] IR: Keep d in vcmov's lanes that don't move under a D prefix With a D prefix the result goes through temps, and a lane the condition doesn't move kept whatever the temp held. They now start as d. That exposed OptimizeFPMoves renaming the src3 of FCmovVfpuCC, which is also its destination, so it no longer replaces a register an op reads as src3 and writes. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 8 ++++++++ Core/MIPS/IR/IRPassSimplify.cpp | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 4970463589..466aa71787 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -2483,6 +2483,14 @@ namespace MIPSComp { DISABLE; } } + // A D prefix that saturates puts the result in temps first. A lane that isn't moved keeps + // d, so they start as d. + u8 realregs[4]; + GetVectorRegs(realregs, sz, _VD); + for (int i = 0; i < n; ++i) { + if (dregs[i] != realregs[i]) + ir.Write(IROp::FMov, dregs[i], realregs[i]); + } // 7 tests a CC bit that is always clear (cpu/vfpu/undefined). if (imm3 != 6) { // Test one bit of CC. This bit decides whether none or all subregisters are copied. diff --git a/Core/MIPS/IR/IRPassSimplify.cpp b/Core/MIPS/IR/IRPassSimplify.cpp index 6a6fc79147..35ba6fc4f6 100644 --- a/Core/MIPS/IR/IRPassSimplify.cpp +++ b/Core/MIPS/IR/IRPassSimplify.cpp @@ -1163,6 +1163,9 @@ bool PurgeTemps(const IRWriter &in, IRWriter &out, const IROptions &opts) { const int numDest = IRDestFPRs(inst, destRegs); if (numDest > 1 && check.srcReg >= destRegs[0] + 32 && check.srcReg < destRegs[0] + 32 + numDest) cannotReplace = true; + // Nor where it's read as src3 that's also the destination, which renaming would move. + if ((inst.m.flags & IRFLAG_SRC3DST) != 0 && inst.m.types[0] == 'F' && inst.src3 + 32 == check.reg) + cannotReplace = true; if (!cannotReplace && check.srcReg >= 32 && lastWrittenTo[check.srcReg] < check.index) { // This is probably not worth doing unless we can get rid of a temp. if (!check.readByExit) { @@ -1173,7 +1176,7 @@ bool PurgeTemps(const IRWriter &in, IRWriter &out, const IROptions &opts) { inst.src1 = srcReg; if (inst.m.types[2] == 'F' && inst.src2 == reg) inst.src2 = srcReg; - if ((inst.m.flags & (IRFLAG_SRC3 | IRFLAG_SRC3DST)) != 0 && inst.m.types[0] == 'F' && inst.src3 == reg) + if ((inst.m.flags & IRFLAG_SRC3) != 0 && inst.m.types[0] == 'F' && inst.src3 == reg) inst.src3 = srcReg; // If this also writes the reg, the check ends here. A full overwrite leaves the From 38322d12ad7b4bc2e88304d56caf89a96764620d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:33:18 +0200 Subject: [PATCH 12/14] IR: vmtvc to a masked control register reads vs It read the VFPU register numbered like the control register instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompVFPU.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/MIPS/IR/IRCompVFPU.cpp b/Core/MIPS/IR/IRCompVFPU.cpp index 466aa71787..9093f46e26 100644 --- a/Core/MIPS/IR/IRCompVFPU.cpp +++ b/Core/MIPS/IR/IRCompVFPU.cpp @@ -1544,7 +1544,7 @@ namespace MIPSComp { if (GetVFPUCtrlMask(imm, &mask)) { u32 setBits = GetVFPUCtrlSetBits(imm); if (mask != 0xFFFFFFFF || setBits != 0) { - ir.Write(IROp::FMovToGPR, IRTEMP_0, vfpuBase + voffset[imm]); + ir.Write(IROp::FMovToGPR, IRTEMP_0, vfpuBase + voffset[vs]); ir.Write(IROp::AndConst, IRTEMP_0, IRTEMP_0, 0, mask); if (setBits != 0) ir.Write(IROp::OrConst, IRTEMP_0, IRTEMP_0, 0, setBits); From 9de0f6e45ddc73176569d4f7011f2505de3db885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 02:33:18 +0200 Subject: [PATCH 13/14] bltzal and friends test rs from before they link As on hardware (cpu/cpu_alu/branch_link_ra), with ra as rs the branch sees its old value, not the return address. Every core linked first. Co-Authored-By: Claude Opus 5.5 (1M context) --- Core/MIPS/IR/IRCompBranch.cpp | 3 ++- Core/MIPS/Interpreter.cpp | 18 ++++++++++-------- pspautotests | 2 +- test.py | 1 + 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Core/MIPS/IR/IRCompBranch.cpp b/Core/MIPS/IR/IRCompBranch.cpp index d010500e56..4161e8823c 100644 --- a/Core/MIPS/IR/IRCompBranch.cpp +++ b/Core/MIPS/IR/IRCompBranch.cpp @@ -135,7 +135,8 @@ void IRFrontend::BranchRSZeroComp(MIPSOpcode op, IRComparison cc, bool andLink, js.downcountAmount += MIPSGetInstructionCycleEstimate(branchInfo.delaySlotOp); MIPSGPReg lhs = rs; - if (!branchInfo.delaySlotIsNice) { // if likely, we don't need this + // The link happens after rs is read, which matters for ra (cpu/cpu_alu/branch_link_ra). + if (!branchInfo.delaySlotIsNice || (andLink && rs == MIPS_REG_RA)) { ir.Write(IROp::Mov, IRTEMP_LHS, rs); lhs = (MIPSGPReg)IRTEMP_LHS; } diff --git a/Core/MIPS/Interpreter.cpp b/Core/MIPS/Interpreter.cpp index 98554ebd11..aacfaa74a8 100644 --- a/Core/MIPS/Interpreter.cpp +++ b/Core/MIPS/Interpreter.cpp @@ -288,16 +288,18 @@ namespace MIPSInt { int imm = _SIMM16_SHL2; int rs = _RS; u32 addr = PC + imm + 4; + // The linking ones test rs from before they link, ra included (cpu/cpu_alu/branch_link_ra). + const s32 value = (s32)R(rs); switch ((op>>16) & 0x1F) { - case 0: if ((s32)R(rs) < 0) DelayBranchTo(mips, addr); else PC += 4; break;//bltz - case 1: if ((s32)R(rs) >= 0) DelayBranchTo(mips, addr); else PC += 4; break;//bgez - case 2: if ((s32)R(rs) < 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bltzl - case 3: if ((s32)R(rs) >= 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bgezl - case 16: R(MIPS_REG_RA) = PC + 8; if ((s32)R(rs) < 0) DelayBranchTo(mips, addr); else PC += 4; break;//bltzal - case 17: R(MIPS_REG_RA) = PC + 8; if ((s32)R(rs) >= 0) DelayBranchTo(mips, addr); else PC += 4; break;//bgezal - case 18: R(MIPS_REG_RA) = PC + 8; if ((s32)R(rs) < 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bltzall - case 19: R(MIPS_REG_RA) = PC + 8; if ((s32)R(rs) >= 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bgezall + case 0: if (value < 0) DelayBranchTo(mips, addr); else PC += 4; break;//bltz + case 1: if (value >= 0) DelayBranchTo(mips, addr); else PC += 4; break;//bgez + case 2: if (value < 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bltzl + case 3: if (value >= 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bgezl + case 16: R(MIPS_REG_RA) = PC + 8; if (value < 0) DelayBranchTo(mips, addr); else PC += 4; break;//bltzal + case 17: R(MIPS_REG_RA) = PC + 8; if (value >= 0) DelayBranchTo(mips, addr); else PC += 4; break;//bgezal + case 18: R(MIPS_REG_RA) = PC + 8; if (value < 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bltzall + case 19: R(MIPS_REG_RA) = PC + 8; if (value >= 0) DelayBranchTo(mips, addr); else SkipLikely(mips); break;//bgezall default: _dbg_assert_msg_(false,"Trying to interpret instruction that can't be interpreted"); break; diff --git a/pspautotests b/pspautotests index 13554e4f6d..a9b26db62a 160000 --- a/pspautotests +++ b/pspautotests @@ -1 +1 @@ -Subproject commit 13554e4f6dc7b3aa0e61327f37f3121e5df9738f +Subproject commit a9b26db62a895c93f87550d37bf3833819bdc49f diff --git a/test.py b/test.py index 354adb33ea..b73ccee4e9 100755 --- a/test.py +++ b/test.py @@ -87,6 +87,7 @@ class Command(object): tests_good = [ "cpu/cpu_alu/cpu_alu", "cpu/cpu_alu/cpu_branch", + "cpu/cpu_alu/branch_link_ra", "cpu/cpu_alu/cpu_branch2", "cpu/cpu_alu/cpu_div", "cpu/cpu_alu/shift_encodings", From ee5f480fc9135300fbc9d3f59badfcce5e6a2f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 15 Oct 2026 03:57:37 +0200 Subject: [PATCH 14/14] Mac ARM64: Switch JIT memory between writable and executable per thread JIT memory is now mapped with MAP_JIT, and pthread_jit_write_protect_np() switches the calling thread's view of it, in place of two mprotect() calls for every block compiled and every exit linked. This makes compiling cheaper, which matters mostly in the first seconds after a game or state loads. A hardened runtime without the JIT entitlement refuses the mapping, and then it's mprotect() as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/CodeBlock.h | 4 +-- Common/MemoryUtil.cpp | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Common/CodeBlock.h b/Common/CodeBlock.h index 04642f16b4..853c15c149 100644 --- a/Common/CodeBlock.h +++ b/Common/CodeBlock.h @@ -99,8 +99,8 @@ public: // If not WX Exclusive, no need to call ProtectMemoryPages because we never change the protection from RWX. PoisonMemory(offset); ResetCodePtr(offset); - if (PlatformIsWXExclusive() && offset != 0) { - // Need to re-protect the part we didn't clear. + if (PlatformIsWXExclusive()) { + // Need to re-protect the part we didn't clear (with MAP_JIT, this thread's view of it all). ProtectMemoryPages(region, offset, MEM_PROT_READ | MEM_PROT_EXEC); } } diff --git a/Common/MemoryUtil.cpp b/Common/MemoryUtil.cpp index 7817c37db3..335a280b67 100644 --- a/Common/MemoryUtil.cpp +++ b/Common/MemoryUtil.cpp @@ -41,6 +41,14 @@ #include #endif +#if PPSSPP_PLATFORM(MAC) && PPSSPP_ARCH(ARM64) +#include +#include +#include +#include +#define USE_MAP_JIT +#endif + #ifndef _WIN32 #include #endif @@ -183,7 +191,43 @@ void *AllocateExecutableMemory(size_t size) { #else // Non-Windows platforms +#ifdef USE_MAP_JIT +// With MAP_JIT, a thread switches all of its view of the JIT memory between writable and executable +// with pthread_jit_write_protect_np(), far cheaper than an mprotect() for every block compiled and +// linked. A hardened runtime without the JIT entitlement refuses the mapping: then mprotect() it is. +static std::mutex g_mapJitLock; +static int g_mapJitState; // 0 untried, 1 in use, -1 refused. +static std::vector> g_mapJitRanges; + +static bool IsMapJitMemory(const void *ptr) { + std::lock_guard guard(g_mapJitLock); + for (const auto &range : g_mapJitRanges) { + if ((uintptr_t)ptr >= range.first && (uintptr_t)ptr < range.second) + return true; + } + return false; +} +#endif + void *AllocateExecutableMemory(size_t size) { +#ifdef USE_MAP_JIT + { + std::lock_guard guard(g_mapJitLock); + if (g_mapJitState >= 0) { + void *ptr = mmap(nullptr, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE | MAP_JIT, -1, 0); + if (ptr != MAP_FAILED) { + g_mapJitState = 1; + g_mapJitRanges.emplace_back((uintptr_t)ptr, (uintptr_t)ptr + size); + return ptr; + } + if (g_mapJitState == 0) { + WARN_LOG(Log::MemMap, "MAP_JIT refused (errno=%d), using mprotect", errno); + g_mapJitState = -1; + } + } + } +#endif + static char *map_hint = nullptr; #if PPSSPP_ARCH(AMD64) @@ -339,6 +383,19 @@ void FreeMemoryPages(void *ptr, size_t size) { } void FreeExecutableMemory(void *ptr, size_t size) { +#ifdef USE_MAP_JIT + if (IsMapJitMemory(ptr)) { + std::lock_guard guard(g_mapJitLock); + for (size_t i = 0; i < g_mapJitRanges.size(); i++) { + if (g_mapJitRanges[i].first == (uintptr_t)ptr) { + g_mapJitRanges.erase(g_mapJitRanges.begin() + i); + break; + } + } + // Back to executable, which the protection change before freeing undid. + pthread_jit_write_protect_np(1); + } +#endif FreeMemoryPages(ptr, size); } @@ -374,6 +431,16 @@ bool ProtectMemoryPages(const void* ptr, size_t size, uint32_t memProtFlags) { _assert_msg_(false, "Bad memory protect flags %d: W^X is in effect, can't both write and exec", memProtFlags); } } +#ifdef USE_MAP_JIT + if (IsMapJitMemory(ptr)) { + _assert_msg_((memProtFlags & (MEM_PROT_WRITE | MEM_PROT_EXEC)) != 0, "MAP_JIT memory is writable or executable"); + pthread_jit_write_protect_np((memProtFlags & MEM_PROT_WRITE) == 0); + return true; + } +#endif + if (size == 0) + return true; + // Note - VirtualProtect will affect the full pages containing the requested range. // mprotect does not seem to, at least not on Android unless I made a mistake somewhere, so we manually round. #ifdef _WIN32