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 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-11-09 13:25:30 +01:00
1 parent b1549195eb
commit 8fbc3adfed
11 files changed
+109

No files matched your search

+1
View File
@@ -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);
+1
View File
@@ -79,6 +79,7 @@ struct CompatFlags {
bool ForceMax60FPS;
bool GoWFramerateHack60;
bool FramerateHack30;
bool LimitFrameRateTo30;
bool HideISOFiles;
bool ForceSoftwareRenderer;
bool DarkStalkersPresentHack;
+3
View File
@@ -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"),
+1
View File
@@ -425,6 +425,7 @@ public:
bool bGfxDebugOutput;
int iInflightFrames;
bool bRenderDuplicateFrames;
int iLimitFrameRateTo30; // FrameRateLimit30
bool bRenderMultiThreading;
// HW debug
+6
View File
@@ -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,
+58
View File
@@ -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<u8> *topaddr, u32 *linesize, GEBufferFormat *pixelFormat, int latchedMode) {
const FrameBufferState &fbState = latchedMode == PSP_DISPLAY_SETBUF_NEXTFRAME ? latchedFramebuf : framebuf;
if (topaddr != nullptr)
+6
View File
@@ -31,6 +31,12 @@ void Register_sceDisplay();
bool __DisplayGetFramebuf(PSPPointer<u8> *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();
+15
View File
@@ -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);
+2
View File
@@ -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;
+1
View File
@@ -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"});
+15
View File
@@ -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