From 255896e89fa4fa71c2550cb8dfab2e4044cfbe48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 5 Sep 2026 13:27:28 -0600 Subject: [PATCH 1/3] Re-check hardcore mode when a savestate op is actually applied The check lived only in Enqueue, but operations don't run there - they're queued and applied later by Process(). During boot, HardcoreModeActive() reads false even when hardcore is on, since it requires rc_client_is_processing_required(), which only becomes true once RetroAchievements has finished identifying the game asynchronously. Anything queued in that window passed the check, and was then applied by Process() after identification completed and hardcore came up. Auto-load wasn't even a race: EmuScreen::bootComplete() calls Achievements::SetGame(), which starts the identify, and then checks HardcoreModeActive() a few lines below - always false at that point. So "Auto load savestate" quietly worked in hardcore mode. --state and a load-state hotkey pressed during boot got through the same way. Re-checking per operation in Process() covers every entry point at once, and by then identification has finished, so the answer is authoritative. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GgACRqkQNpfJQ4fjwyoEup --- Core/SaveState.cpp | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Core/SaveState.cpp b/Core/SaveState.cpp index db983922ca..8f4ba4a853 100644 --- a/Core/SaveState.cpp +++ b/Core/SaveState.cpp @@ -196,17 +196,18 @@ int g_screenshotFailures; pspFileSystem.DoState(p); } - void Enqueue(const SaveState::Operation &op) { - if (!NetworkAllowSaveState()) { - return; + // Hardcore mode bans loading savestates outright, and saving too unless the user opted + // back into that. + bool BannedInHardcoreMode(OperationType type) { + if (!Achievements::HardcoreModeActive()) { + return false; } - if (Achievements::HardcoreModeActive()) { - if (g_Config.bAchievementsSaveStateInHardcoreMode && ((op.type == SaveState::OperationType::Save))) { - // We allow saving in hardcore mode if this setting is on. - } else { - // Operation not allowed - return; - } + return !(g_Config.bAchievementsSaveStateInHardcoreMode && type == OperationType::Save); + } + + void Enqueue(const SaveState::Operation &op) { + if (!NetworkAllowSaveState() || BannedInHardcoreMode(op.type)) { + return; } std::lock_guard guard(mutex); @@ -810,6 +811,17 @@ int g_screenshotFailures; SaveStart state; for (const auto &op : operations) { + // Re-check here, and not just in Enqueue: during boot, RetroAchievements is still + // identifying the game asynchronously, and until it's done hardcore mode doesn't + // read as active yet. An operation queued in that window (a hotkey press, --state, + // auto-load) passed the check in Enqueue and would otherwise be applied here, after + // identification has finished and hardcore mode has come up. + if (BannedInHardcoreMode(op.type)) { + WARN_LOG(Log::SaveState, "Dropping queued savestate operation - hardcore mode is active"); + Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save); + continue; + } + CChunkFileReader::Error result; Status callbackResult; std::string callbackMessage; From be834b44464167d360486486a9e01e35d94859e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 5 Sep 2026 13:29:50 -0600 Subject: [PATCH 2/3] Ban freeze-frame in hardcore mode, and tell the user when a savestate is refused Freeze-frame restores a savestate every frame, straight through SaveState::LoadFromRam(), so it never touched the operation queue and neither hardcore check saw it. Blocked at the toggle in the dev menu, and again in the render loop, since hardcore mode can come up after the fact once the game has been identified. Enqueue also just dropped operations silently, so a load that arrived through a path with no check of its own (--state, auto-load) did nothing with no explanation. Both it and Process now go through WarnUserIfHardcoreModeActive, which is the same predicate plus the standard message. Callers that already ask it themselves return before reaching Enqueue, so nothing shows the message twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GgACRqkQNpfJQ4fjwyoEup --- Core/SaveState.cpp | 21 +++++++++------------ UI/DevScreens.cpp | 5 +++++ UI/EmuScreen.cpp | 10 ++++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Core/SaveState.cpp b/Core/SaveState.cpp index 8f4ba4a853..309e36c63c 100644 --- a/Core/SaveState.cpp +++ b/Core/SaveState.cpp @@ -196,17 +196,15 @@ int g_screenshotFailures; pspFileSystem.DoState(p); } - // Hardcore mode bans loading savestates outright, and saving too unless the user opted - // back into that. - bool BannedInHardcoreMode(OperationType type) { - if (!Achievements::HardcoreModeActive()) { - return false; - } - return !(g_Config.bAchievementsSaveStateInHardcoreMode && type == OperationType::Save); - } - void Enqueue(const SaveState::Operation &op) { - if (!NetworkAllowSaveState() || BannedInHardcoreMode(op.type)) { + if (!NetworkAllowSaveState()) { + return; + } + // Hardcore mode bans loading savestates outright, and saving too unless the user opted + // back into that. Callers that ask WarnUserIfHardcoreModeActive themselves never get + // this far, so there's no double message - this is for the paths that go straight to + // the queue, like --state and auto-load. + if (Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save)) { return; } @@ -816,9 +814,8 @@ int g_screenshotFailures; // read as active yet. An operation queued in that window (a hotkey press, --state, // auto-load) passed the check in Enqueue and would otherwise be applied here, after // identification has finished and hardcore mode has come up. - if (BannedInHardcoreMode(op.type)) { + if (Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save)) { WARN_LOG(Log::SaveState, "Dropping queued savestate operation - hardcore mode is active"); - Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save); continue; } diff --git a/UI/DevScreens.cpp b/UI/DevScreens.cpp index 5c43b5c25f..c652c919fa 100644 --- a/UI/DevScreens.cpp +++ b/UI/DevScreens.cpp @@ -58,6 +58,7 @@ #include "Core/ConfigValues.h" #include "Core/System.h" #include "Core/Reporting.h" +#include "Core/RetroAchievements.h" #include "Core/CoreParameter.h" #include "Core/HLE/sceKernel.h" // GPI/GPO #include "Core/MIPS/MIPSTables.h" @@ -187,6 +188,10 @@ void DevMenuScreen::CreatePopupContents(UI::ViewGroup *parent) { }); items->Add(new Choice(dev->T("Toggle Freeze")))->OnClick.Add([](UI::EventParams &e) { + // Freezing restores a savestate every frame, so it's not allowed in hardcore mode. + if (Achievements::WarnUserIfHardcoreModeActive(false)) { + return; + } if (PSP_CoreParameter().frozen) { PSP_CoreParameter().frozen = false; } else { diff --git a/UI/EmuScreen.cpp b/UI/EmuScreen.cpp index 618ee0dd72..014db251d9 100644 --- a/UI/EmuScreen.cpp +++ b/UI/EmuScreen.cpp @@ -1709,8 +1709,14 @@ ScreenRenderFlags EmuScreen::RunEmulation(bool skipBufferEffects) { gpu->BeginHostFrame(displayLayoutConfig); } - // Freeze-frame functionality (loads a savestate on every frame). - if (PSP_CoreParameter().freezeNext) { + // Freeze-frame functionality (loads a savestate on every frame). It's a savestate load + // like any other, so it's banned in hardcore mode - and checked here rather than only + // where it's toggled, since hardcore mode can come up after the fact, once the game + // has been identified. + if (Achievements::HardcoreModeActive()) { + PSP_CoreParameter().freezeNext = false; + PSP_CoreParameter().frozen = false; + } else if (PSP_CoreParameter().freezeNext) { PSP_CoreParameter().frozen = true; PSP_CoreParameter().freezeNext = false; SaveState::SaveToRam(freezeState_); From 7d14efc331849f77d2bbef50331ebd587147ebac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sat, 5 Sep 2026 13:34:12 -0600 Subject: [PATCH 3/3] Say why a savestate is refused while online, instead of dropping it silently Same problem as the hardcore checks: SaveState.cpp asked NetworkAllowSaveState() and just returned, so a load or save refused because you're connected did nothing at all, with no explanation. Switched all eight to NetworkWarnUserIfOnlineAndCantSavestate(), which is the same predicate plus the standard message; its OSD id already collapses duplicates for the paths that check twice on the way in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GgACRqkQNpfJQ4fjwyoEup --- Core/SaveState.cpp | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Core/SaveState.cpp b/Core/SaveState.cpp index 309e36c63c..1dee9fa660 100644 --- a/Core/SaveState.cpp +++ b/Core/SaveState.cpp @@ -197,13 +197,14 @@ int g_screenshotFailures; } void Enqueue(const SaveState::Operation &op) { - if (!NetworkAllowSaveState()) { + // These two both refuse the operation, and both say so - dropping it silently just + // looks like the hotkey didn't work. Callers that ask the same questions themselves + // return before getting here, so nothing shows a message twice. + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return; } // Hardcore mode bans loading savestates outright, and saving too unless the user opted - // back into that. Callers that ask WarnUserIfHardcoreModeActive themselves never get - // this far, so there's no double message - this is for the paths that go straight to - // the queue, like --state and auto-load. + // back into that. if (Achievements::WarnUserIfHardcoreModeActive(op.type == OperationType::Save)) { return; } @@ -217,7 +218,7 @@ int g_screenshotFailures; } void Load(const Path &filename, int slot, Callback callback) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return; } @@ -228,7 +229,7 @@ int g_screenshotFailures; } void Save(const Path &filename, int slot, Callback callback) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return; } @@ -398,7 +399,7 @@ int g_screenshotFailures; } void LoadSlot(std::string_view gamePrefix, int slot, Callback callback) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return; } @@ -439,7 +440,7 @@ int g_screenshotFailures; } bool UndoLoad(std::string_view gamePrefix, Callback callback) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return false; } @@ -465,7 +466,7 @@ int g_screenshotFailures; } void SaveSlot(std::string_view gamePrefix, int slot, Callback callback) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return; } @@ -510,7 +511,7 @@ int g_screenshotFailures; } bool UndoSaveSlot(std::string_view gamePrefix, int slot) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return false; } @@ -543,7 +544,7 @@ int g_screenshotFailures; } bool UndoLastSave(std::string_view gamePrefix) { - if (!NetworkAllowSaveState()) { + if (NetworkWarnUserIfOnlineAndCantSavestate()) { return false; }