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