From 8fbc3adfed545ed2a51e368d2c3d544a09f1ed6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 9 Nov 2026 13:25:30 +0100 Subject: [PATCH] Add a "Limit framerate to 30 fps" setting, and a compat flag for it Many games drop to 30fps when they miss a vblank, and on the PSP some of them nearly always do. In PPSSPP the GE is fast enough that they spend much of their time at 60 instead, and some things misbehave (Midnight Club). With the limit on, the GE takes until just after the vblank two on from the one the previous frame was done in, so the frame goes up every other vblank whether the game flips as soon as it's done, waits for a vblank first, or latches the flip. A new frame starts at the first list after a flip, or at a list that starts after a vblank, for games that enqueue their lists from the vblank interrupt and never flip (Midnight Club: LA Remix). Auto applies it to games with the LimitFrameRateTo30 compat flag, the two Midnight Club games for now. Co-Authored-By: Claude Opus 5.5 --- Core/Compatibility.cpp | 1 + Core/Compatibility.h | 1 + Core/Config.cpp | 3 ++ Core/Config.h | 1 + Core/ConfigValues.h | 6 ++++ Core/HLE/sceDisplay.cpp | 58 ++++++++++++++++++++++++++++++++++++ Core/HLE/sceDisplay.h | 6 ++++ GPU/GPUCommon.cpp | 15 ++++++++++ GPU/GPUCommon.h | 2 ++ UI/Settings/GraphicsPage.cpp | 1 + assets/compat.ini | 15 ++++++++++ 11 files changed, 109 insertions(+) diff --git a/Core/Compatibility.cpp b/Core/Compatibility.cpp index ce166eebe6..4b04e9132d 100644 --- a/Core/Compatibility.cpp +++ b/Core/Compatibility.cpp @@ -113,6 +113,7 @@ void Compatibility::CheckSettings(IniFile &iniFile, const std::string &gameID) { CheckSetting(iniFile, gameID, "ForceMax60FPS", &flags_.ForceMax60FPS); CheckSetting(iniFile, gameID, "GoWFramerateHack60", &flags_.GoWFramerateHack60); CheckSetting(iniFile, gameID, "FramerateHack30", &flags_.FramerateHack30); + CheckSetting(iniFile, gameID, "LimitFrameRateTo30", &flags_.LimitFrameRateTo30); CheckSetting(iniFile, gameID, "HideISOFiles", &flags_.HideISOFiles); CheckSetting(iniFile, gameID, "ForceSoftwareRenderer", &flags_.ForceSoftwareRenderer); CheckSetting(iniFile, gameID, "DarkStalkersPresentHack", &flags_.DarkStalkersPresentHack); diff --git a/Core/Compatibility.h b/Core/Compatibility.h index 647c41b9cb..2d9c65e300 100644 --- a/Core/Compatibility.h +++ b/Core/Compatibility.h @@ -79,6 +79,7 @@ struct CompatFlags { bool ForceMax60FPS; bool GoWFramerateHack60; bool FramerateHack30; + bool LimitFrameRateTo30; bool HideISOFiles; bool ForceSoftwareRenderer; bool DarkStalkersPresentHack; diff --git a/Core/Config.cpp b/Core/Config.cpp index 349db597cd..85685e6649 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -899,6 +899,7 @@ static constexpr const char *androidHwScaleNames[] = { "Native device resolution static constexpr const char *frameSkipNames[] = { "Off", "1", "2", "3", "4" }; static constexpr const char *skipGPUReadbackModeNames[] = { "No", "Skip", "Copy to texture" }; static constexpr const char *depthRasterModeNames[] = { "Auto", "Low", "Off", "Always on" }; +static constexpr const char *frameRateLimit30Names[] = { "Auto", "Always", "Off" }; static constexpr const char *bloomHackNames[] = { "Off", "Safe", "Balanced", "Aggressive" }; static constexpr const char *inflightFramesNames[] = { "No buffer", "Up to 1", "Up to 2" }; static constexpr const char *texScalingTypeNames[] = { "xBRZ", "Hybrid", "Bicubic", "Hybrid + Bicubic" }; @@ -1043,6 +1044,8 @@ static constexpr ConfigSetting graphicsSettings[] = { .Label(I18NCat::GRAPHICS, "Buffer graphics commands").Choices(inflightFramesNames, 1), ConfigSetting("RenderDuplicateFrames", SETTING(g_Config, bRenderDuplicateFrames), false, CfgFlag::PER_GAME) .Label(I18NCat::GRAPHICS, "Render duplicate frames to 60hz"), + ConfigSetting("LimitFrameRateTo30", SETTING(g_Config, iLimitFrameRateTo30), (int)FrameRateLimit30::AUTO, CfgFlag::PER_GAME | CfgFlag::REPORT) + .Label(I18NCat::GRAPHICS, "Limit framerate to 30 fps").Choices(frameRateLimit30Names), ConfigSetting("MultiThreading", SETTING(g_Config, bRenderMultiThreading), true, CfgFlag::DEFAULT) .Label(I18NCat::DEVELOPER, "Multi-threaded rendering"), diff --git a/Core/Config.h b/Core/Config.h index 9548d8df4b..58dbf110c9 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -425,6 +425,7 @@ public: bool bGfxDebugOutput; int iInflightFrames; bool bRenderDuplicateFrames; + int iLimitFrameRateTo30; // FrameRateLimit30 bool bRenderMultiThreading; // HW debug diff --git a/Core/ConfigValues.h b/Core/ConfigValues.h index aefd828dbf..64d2baae98 100644 --- a/Core/ConfigValues.h +++ b/Core/ConfigValues.h @@ -301,6 +301,12 @@ enum class DebugOverlay : int { JIT_STATS, }; +enum class FrameRateLimit30 : int { + AUTO = 0, // Games with the LimitFrameRateTo30 compat flag. + ALWAYS = 1, + OFF = 2, +}; + // Android-only for now enum class DisplayFramerateMode : int { DEFAULT, diff --git a/Core/HLE/sceDisplay.cpp b/Core/HLE/sceDisplay.cpp index 13b28d8b8d..aeb8e64f29 100644 --- a/Core/HLE/sceDisplay.cpp +++ b/Core/HLE/sceDisplay.cpp @@ -172,6 +172,14 @@ static int lastFlipsTooFrequent = 0; static u64 lastFlipCycles = 0; static u64 nextFlipCycles = 0; +// For limiting the frame rate to 30fps. Not saved: after a load, pacing starts over. +static u64 lastVblankTicks = 0; +// When the GE was done with the last frame, paced or not. 0 when there's none yet. +static u64 pacedFrameDoneTicks = 0; +static bool flippedSincePacedFrame = false; +// How far past a vblank a paced frame is done. +static const double PACE_MARGIN_MS = 1.0; + void hleEnterVblank(u64 userdata, int cyclesLate); void hleLeaveVblank(u64 userdata, int cyclesLate); static void hleVblankWake(u64 userdata, int cyclesLate); @@ -227,6 +235,9 @@ void __DisplayInit() { lastFlipsTooFrequent = 0; lastFlipCycles = 0; nextFlipCycles = 0; + lastVblankTicks = 0; + pacedFrameDoneTicks = 0; + flippedSincePacedFrame = false; wasPaused = false; enterVblankEvent = CoreTiming::RegisterEvent("EnterVBlank", &hleEnterVblank); @@ -351,6 +362,9 @@ void __DisplayDoState(PointerWrap &p) { if (p.mode == p.MODE_READ) { // Not saved. Start counting again rather than carry over the session before the load. lastFlipsTooFrequent = 0; + lastVblankTicks = 0; + pacedFrameDoneTicks = 0; + flippedSincePacedFrame = false; } gpu->DoState(p); @@ -588,6 +602,8 @@ void hleEnterVblank(u64 userdata, int cyclesLate) { VERBOSE_LOG(Log::sceDisplay, "Enter VBlank %i", vbCount); + lastVblankTicks = CoreTiming::GetTicks(currentMIPS) - cyclesLate; + __PowerCheckLockedClock(); DisplayFireVblankStart(); @@ -1031,6 +1047,10 @@ int sceDisplaySetFramebuf(MIPSState *mips, u32 topaddr, int linesize, int pixelf __DisplaySetFramebuf(topaddr, linesize, pixelformat, sync); + if (topaddr != 0) { + flippedSincePacedFrame = true; + } + // No delaying while inside an interrupt. It'll cause idle threads to starve. if (delayCycles > 0 && !__IsInInterrupt()) { // Okay, the game is going at too high a frame rate. God of War and Fat Princess both do this. @@ -1046,6 +1066,44 @@ int sceDisplaySetFramebuf(MIPSState *mips, u32 topaddr, int linesize, int pixelf } } +// The start of the vblank that ticks falls in (it can be in the future, for a list the GE isn't done with). +static s64 VblankAtOrBefore(u64 ticks, s64 frameCycles) { + s64 offset = (s64)(ticks - lastVblankTicks); + s64 frames = offset >= 0 ? offset / frameCycles : -((-offset + frameCycles - 1) / frameCycles); + return (s64)lastVblankTicks + frames * frameCycles; +} + +bool __DisplayLimitTo30FPS() { + switch ((FrameRateLimit30)g_Config.iLimitFrameRateTo30) { + case FrameRateLimit30::ALWAYS: return true; + case FrameRateLimit30::OFF: return false; + default: return PSP_CoreParameter().compat.flags().LimitFrameRateTo30; + } +} + +u64 __DisplayPaceFrameEnd(u64 listStartTicks, u64 doneTicks) { + if (lastVblankTicks == 0) { + return doneTicks; + } + const s64 frameCycles = msToCycles(frameMs); + // The first list after a flip starts a new frame. Some games (Midnight Club) never flip during + // gameplay, and enqueue each frame's list from the vblank interrupt instead, so a list that starts + // after a vblank does too. Other lists are part of the frame before. + if (pacedFrameDoneTicks != 0 && !flippedSincePacedFrame && (s64)listStartTicks < VblankAtOrBefore(pacedFrameDoneTicks, frameCycles) + frameCycles) { + return doneTicks; + } + u64 endTicks = doneTicks; + if (pacedFrameDoneTicks != 0) { + // Just after the vblank two on from the one the previous frame was done in. Whether the game flips + // as soon as it's done or waits for a vblank, latched or not, the flip lands every other vblank. + const s64 paceTicks = VblankAtOrBefore(pacedFrameDoneTicks, frameCycles) + 2 * frameCycles + msToCycles(PACE_MARGIN_MS); + endTicks = std::max(endTicks, (u64)paceTicks); + } + pacedFrameDoneTicks = endTicks; + flippedSincePacedFrame = false; + return endTicks; +} + bool __DisplayGetFramebuf(PSPPointer *topaddr, u32 *linesize, GEBufferFormat *pixelFormat, int latchedMode) { const FrameBufferState &fbState = latchedMode == PSP_DISPLAY_SETBUF_NEXTFRAME ? latchedFramebuf : framebuf; if (topaddr != nullptr) diff --git a/Core/HLE/sceDisplay.h b/Core/HLE/sceDisplay.h index edb3bc6b23..9a29387093 100644 --- a/Core/HLE/sceDisplay.h +++ b/Core/HLE/sceDisplay.h @@ -31,6 +31,12 @@ void Register_sceDisplay(); bool __DisplayGetFramebuf(PSPPointer *topaddr, u32 *linesize, GEBufferFormat *pixelFormat, int mode); void __DisplaySetFramebuf(u32 topaddr, int linesize, int pixelformat, int sync); +// Whether to limit the frame rate to 30fps, from the setting and the compat flag. +bool __DisplayLimitTo30FPS(); +// For that: when the GE should be done with a list that started at listStartTicks and would be done +// at doneTicks, so its frame goes up every other vblank. Returns doneTicks or later. +u64 __DisplayPaceFrameEnd(u64 listStartTicks, u64 doneTicks); + // Call this when resuming to avoid a small speedup burst void __DisplaySetWasPaused(); diff --git a/GPU/GPUCommon.cpp b/GPU/GPUCommon.cpp index 463833ec61..93aa816aec 100644 --- a/GPU/GPUCommon.cpp +++ b/GPU/GPUCommon.cpp @@ -26,6 +26,7 @@ #include "Core/HLE/ErrorCodes.h" #include "Core/HLE/sceKernelMemory.h" #include "Core/HLE/sceKernelInterrupt.h" +#include "Core/HLE/sceDisplay.h" #include "Core/HLE/sceGe.h" #include "Core/HLE/scePower.h" #include "Core/Util/PPGeDraw.h" @@ -996,6 +997,9 @@ DLResult GPUCommon::ProcessDLQueue() { if (!list.started && list.context.IsValid()) { gstate.Save(list.context); } + if (!list.started) { + paceListStartTicks_ = startingTicks + cyclesExecuted; + } list.started = true; gstate_c.offsetAddr = list.offsetAddr; @@ -1426,6 +1430,14 @@ void GPUCommon::Execute_End(u32 op, u32 diff) { currentList->subIntrToken = prev & 0xFFFF; UpdateState(GPUSTATE_DONE); + if (__DisplayLimitTo30FPS()) { + // Make the GE look slow enough that the frame misses the next vblank. Games that drop + // to 30fps when they can't make 60 then do. Everything that follows (interrupt, list + // and draw sync, busy time) is timed from here. + const u64 doneTicks = startingTicks + cyclesExecuted; + cyclesExecuted += (int)(__DisplayPaceFrameEnd(paceListStartTicks_, doneTicks) - doneTicks); + } + if (currentList->interruptsEnabled && __GeTriggerInterrupt(currentList->id, currentList->pc, startingTicks + cyclesExecuted)) { // The context is restored once the finish callback has run, which gets to see the state // the list left behind. Nothing else runs before then, see InterruptEnd(). @@ -2083,6 +2095,9 @@ void GPUCommon::DoState(PointerWrap &p) { Do(p, isbreak); Do(p, drawCompleteTicks); Do(p, busyTicks); + if (p.mode == PointerWrap::MODE_READ) { + paceListStartTicks_ = 0; + } if (s >= 5) { Do(p, matrixVisible.all); diff --git a/GPU/GPUCommon.h b/GPU/GPUCommon.h index e2f1f20c6f..9ad8ac0170 100644 --- a/GPU/GPUCommon.h +++ b/GPU/GPUCommon.h @@ -430,6 +430,8 @@ protected: u64 startingTicks; u32 cycleLastPC; int cyclesExecuted; + // For the 30fps limit: when the last list started. Not saved. + u64 paceListStartTicks_ = 0; bool resumingFromDebugBreak_ = false; bool dumpNextFrame_ = false; diff --git a/UI/Settings/GraphicsPage.cpp b/UI/Settings/GraphicsPage.cpp index efbf9f5c94..b3ca1fecb3 100644 --- a/UI/Settings/GraphicsPage.cpp +++ b/UI/Settings/GraphicsPage.cpp @@ -317,6 +317,7 @@ void BuildGraphicsSettings(SettingsPage *page) { if (KeyMap::InputMappingsFromPspButton(VIRTKEY_SPEED_ANALOG, nullptr, true)) { page->Setting(&g_Config.iAnalogAltSpeedPercent); } + page->Setting(&g_Config.iLimitFrameRateTo30).Hint({I18NCat::GRAPHICS, "LimitFrameRateTo30 Tip", "Some games run better if limited to 30 fps. Auto is recommended, Always can cause problems in some games."}); page->Header(); page->Link({I18NCat::GRAPHICS, "Speed Hacks"}, I18NCat::GRAPHICS, &BuildSpeedHacksPage).Hint({I18NCat::GRAPHICS, "SpeedHacksHint", "These settings can cause rendering errors"}); diff --git a/assets/compat.ini b/assets/compat.ini index 10a0abb7fc..bbf6c135f5 100644 --- a/assets/compat.ini +++ b/assets/compat.ini @@ -2320,3 +2320,18 @@ ULJM05395 = true ULJM05884 = true NPJH50827 = true ULET00417 = true + +[LimitFrameRateTo30] +# Makes each frame's GE work finish just after the vblank following the previous flip, as if the GE +# were slower. Games that fall back to 30fps when they miss a vblank then run at 30fps throughout, +# like they mostly do on hardware, instead of jumping between 30 and 60. +# Midnight Club: LA Remix +ULUS10383 = true +ULES01144 = true +ULJS00180 = true +ULJS00267 = true +ULJM05904 = true +NPJH50440 = true +# Midnight Club 3: DUB Edition +ULUS10021 = true +ULES00108 = true