From dcab1dd7d0f351e97b7da85d948dbcf98fdaa809 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Tue, 4 Aug 2026 13:15:00 -1000 Subject: [PATCH 1/3] Include StaticRecompCore.h where the interpreter uses it unknown_instruction reads g_static_recomp_core and ppc_state, but the translation unit includes neither the header that declares the former nor a binding for the latter, so core does not compile: Interpreter.cpp(294): error C2065: 'g_static_recomp_core': undeclared identifier Interpreter.cpp(299): error C2065: 'ppc_state': undeclared identifier It is not reached through the PCH either -- pch.h does not pull in StaticRecompCore.h. The ppc_state binding already existed further down the same function, past the first use. Moving it to the top serves both uses rather than adding a second one, which would be a redefinition. --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 4d0bbbfa59..2fa8cde1dc 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -23,6 +23,7 @@ #include "Core/PowerPC/MMU.h" #include "Core/PowerPC/PPCTables.h" #include "Core/PowerPC/PowerPC.h" +#include "Core/PowerPC/StaticRecomp/StaticRecompCore.h" #include "Core/System.h" namespace @@ -287,6 +288,7 @@ void Interpreter::Run() void Interpreter::unknown_instruction(Interpreter& interpreter, UGeckoInstruction inst) { ASSERT(Core::IsCPUThread()); + auto& ppc_state = interpreter.m_ppc_state; auto& system = interpreter.m_system; Core::CPUThreadGuard guard(system); @@ -337,7 +339,6 @@ void Interpreter::unknown_instruction(Interpreter& interpreter, UGeckoInstructio Dolphin_Debugger::PrintCallstack(guard, Common::Log::LogType::POWERPC, Common::Log::LogLevel::LNOTICE); - const auto& ppc_state = interpreter.m_ppc_state; NOTICE_LOG_FMT( POWERPC, "\nIntCPU: Unknown instruction {:08x} at PC = {:08x} last_PC = {:08x} LR = {:08x}\n", From 25a593fb5c5ab9a5e9921f54f37381db3828702e Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Tue, 4 Aug 2026 22:04:50 -1000 Subject: [PATCH 2/3] Give the Win32 render window a File/View menu and hotkeys DolphinNoGUI's window had no way to save or load a state, and Esc as the only key. Getting back to a scene meant booting and playing in. File carries Save State (F1), a Load State submenu, and Pause. View carries Fullscreen (F11 or Alt+Enter) and Mute Audio. Space held is fast-forward. All of it goes through Core, State and Config directly rather than new Host_* hooks, so no frontend has to implement anything to pick this up. States are written to and listed from Dolphin's own StateSaves directory, named by wall clock rather than by slot, so repeated saves accumulate instead of overwriting each other and sort meaningfully by name. The Load State list is rebuilt whenever the menu opens, because states appear while it is closed; it is newest-first with a filename tiebreak so the order does not shuffle between opens on a filesystem with coarse timestamps. F1 ignores auto-repeat -- bit 30 of lParam -- or holding it down would write a state every few milliseconds. Fast-forward is also cancelled on WM_KILLFOCUS, so alt-tabbing mid-hold cannot leave emulation stuck at 2x with the key-up delivered elsewhere. --- Source/Core/DolphinNoGUI/PlatformWin32.cpp | 260 ++++++++++++++++++++- 1 file changed, 258 insertions(+), 2 deletions(-) diff --git a/Source/Core/DolphinNoGUI/PlatformWin32.cpp b/Source/Core/DolphinNoGUI/PlatformWin32.cpp index d2e2f2fba5..168415baae 100644 --- a/Source/Core/DolphinNoGUI/PlatformWin32.cpp +++ b/Source/Core/DolphinNoGUI/PlatformWin32.cpp @@ -6,9 +6,18 @@ #include "Core/Config/MainSettings.h" #include "Core/Config/ConfigManager.h" #include "Core/Core.h" +#include "Core/State.h" #include "Core/System.h" +#include "Common/CommonPaths.h" +#include "Common/FileUtil.h" + +#include #include +#include +#include +#include +#include #include #include #include @@ -19,6 +28,19 @@ namespace { +// Menu command ids. Load State entries are allocated a contiguous range, +// since the list is rebuilt from disk each time the menu opens. +constexpr UINT ID_SAVE_STATE = 41001; +constexpr UINT ID_PAUSE = 41002; +constexpr UINT ID_MUTE = 41003; +constexpr UINT ID_FULLSCREEN = 41004; +constexpr UINT ID_LOAD_STATE_FIRST = 41100; +constexpr UINT ID_LOAD_STATE_LAST = 41199; + +// Hold-to-fast-forward target. 2x is fast enough to skip a cutscene without +// outrunning what most hosts can actually emulate. +constexpr float FAST_FORWARD_SPEED = 2.0f; + class PlatformWin32 final : public Platform { public: @@ -37,10 +59,23 @@ class PlatformWin32 final : public Platform static bool RegisterRenderWindowClass(); bool CreateRenderWindow(); + bool CreateMenus(); + void RefreshMenu(HMENU menu); + void SaveStateToStatesDirectory(); + void ToggleFullscreen(); void UpdateWindowPosition(); void ProcessEvents(); HWND m_hwnd{}; + HMENU m_menu{}; + HMENU m_file_menu{}; + HMENU m_load_menu{}; + HMENU m_view_menu{}; + // Parallel to the Load State menu entries, rebuilt whenever it opens. + std::vector m_load_state_paths; + bool m_fullscreen = false; + LONG m_windowed_style = 0; + RECT m_windowed_rect{}; int m_window_x = Config::Get(Config::MAIN_RENDER_WINDOW_XPOS); int m_window_y = Config::Get(Config::MAIN_RENDER_WINDOW_YPOS); @@ -96,9 +131,147 @@ bool PlatformWin32::CreateRenderWindow() return true; } +bool PlatformWin32::CreateMenus() +{ + m_menu = CreateMenu(); + m_file_menu = CreatePopupMenu(); + m_load_menu = CreatePopupMenu(); + m_view_menu = CreatePopupMenu(); + if (!m_menu || !m_file_menu || !m_load_menu || !m_view_menu) + return false; + + AppendMenuW(m_file_menu, MF_STRING, ID_SAVE_STATE, L"&Save State\tF1"); + AppendMenuW(m_file_menu, MF_POPUP, reinterpret_cast(m_load_menu), L"&Load State"); + AppendMenuW(m_file_menu, MF_SEPARATOR, 0, nullptr); + // Checked state is refreshed from the core when the menu opens, so it cannot + // drift out of step with an emulation that was paused some other way. + AppendMenuW(m_file_menu, MF_STRING, ID_PAUSE, L"&Pause"); + + AppendMenuW(m_view_menu, MF_STRING, ID_FULLSCREEN, L"&Fullscreen\tAlt+Enter"); + AppendMenuW(m_view_menu, MF_STRING, ID_MUTE, L"&Mute Audio"); + + AppendMenuW(m_menu, MF_POPUP, reinterpret_cast(m_file_menu), L"&File"); + AppendMenuW(m_menu, MF_POPUP, reinterpret_cast(m_view_menu), L"&View"); + return SetMenu(m_hwnd, m_menu) != FALSE; +} + +// Rebuilt on open rather than cached: states are written by this process while +// the menu is closed, and by the launcher between sessions. +void PlatformWin32::RefreshMenu(const HMENU menu) +{ + if (menu == m_file_menu) + { + auto& system = Core::System::GetInstance(); + const bool paused = Core::GetState(system) == Core::State::Paused; + CheckMenuItem(m_file_menu, ID_PAUSE, MF_BYCOMMAND | (paused ? MF_CHECKED : MF_UNCHECKED)); + return; + } + + if (menu == m_view_menu) + { + CheckMenuItem(m_view_menu, ID_MUTE, + MF_BYCOMMAND | + (Config::Get(Config::MAIN_AUDIO_MUTED) ? MF_CHECKED : MF_UNCHECKED)); + CheckMenuItem(m_view_menu, ID_FULLSCREEN, + MF_BYCOMMAND | (m_fullscreen ? MF_CHECKED : MF_UNCHECKED)); + return; + } + + if (menu != m_load_menu) + return; + + while (DeleteMenu(m_load_menu, 0, MF_BYPOSITION)) + { + } + m_load_state_paths.clear(); + + const std::string directory = File::GetUserPath(D_STATESAVES_IDX); + std::vector states; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) + { + if (entry.is_regular_file(ec) && entry.path().extension() == ".sav") + states.push_back(entry.path()); + } + // Newest first: the state wanted next is nearly always the one just written. + // Filename breaks ties so the order does not shuffle between opens on a + // filesystem whose timestamps are coarse. + std::sort(states.begin(), states.end(), + [](const std::filesystem::path& left, const std::filesystem::path& right) { + std::error_code left_ec; + std::error_code right_ec; + const auto left_time = std::filesystem::last_write_time(left, left_ec); + const auto right_time = std::filesystem::last_write_time(right, right_ec); + if (!left_ec && !right_ec && left_time != right_time) + return left_time > right_time; + return left.filename().string() < right.filename().string(); + }); + + if (states.empty()) + { + AppendMenuW(m_load_menu, MF_STRING | MF_GRAYED, 0, L"(no savestates)"); + return; + } + + const std::size_t limit = std::min( + states.size(), ID_LOAD_STATE_LAST - ID_LOAD_STATE_FIRST + 1); + for (std::size_t i = 0; i < limit; ++i) + { + AppendMenuW(m_load_menu, MF_STRING, ID_LOAD_STATE_FIRST + i, + states[i].filename().wstring().c_str()); + m_load_state_paths.push_back(states[i].string()); + } +} + +void PlatformWin32::SaveStateToStatesDirectory() +{ + // Named by wall-clock rather than by slot, so repeated saves accumulate + // instead of overwriting one another, and sort meaningfully by name. + const std::time_t now = std::time(nullptr); + std::tm local{}; + localtime_s(&local, &now); + char stamp[32]; + std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &local); + + const std::string directory = File::GetUserPath(D_STATESAVES_IDX); + File::CreateFullPath(directory); + State::SaveAs(Core::System::GetInstance(), directory + "state-" + stamp + ".sav"); +} + +void PlatformWin32::ToggleFullscreen() +{ + if (!m_fullscreen) + { + GetWindowRect(m_hwnd, &m_windowed_rect); + m_windowed_style = GetWindowLong(m_hwnd, GWL_STYLE); + + MONITORINFO monitor{}; + monitor.cbSize = sizeof(monitor); + if (!GetMonitorInfo(MonitorFromWindow(m_hwnd, MONITOR_DEFAULTTONEAREST), &monitor)) + return; + + SetMenu(m_hwnd, nullptr); + SetWindowLong(m_hwnd, GWL_STYLE, m_windowed_style & ~WS_OVERLAPPEDWINDOW); + SetWindowPos(m_hwnd, HWND_TOP, monitor.rcMonitor.left, monitor.rcMonitor.top, + monitor.rcMonitor.right - monitor.rcMonitor.left, + monitor.rcMonitor.bottom - monitor.rcMonitor.top, + SWP_NOOWNERZORDER | SWP_FRAMECHANGED); + m_fullscreen = true; + return; + } + + SetWindowLong(m_hwnd, GWL_STYLE, m_windowed_style); + SetMenu(m_hwnd, m_menu); + SetWindowPos(m_hwnd, nullptr, m_windowed_rect.left, m_windowed_rect.top, + m_windowed_rect.right - m_windowed_rect.left, + m_windowed_rect.bottom - m_windowed_rect.top, + SWP_NOOWNERZORDER | SWP_FRAMECHANGED | SWP_NOZORDER); + m_fullscreen = false; +} + bool PlatformWin32::Init() { - if (!RegisterRenderWindowClass() || !CreateRenderWindow()) + if (!RegisterRenderWindowClass() || !CreateRenderWindow() || !CreateMenus()) return false; // TODO: Enter fullscreen if enabled. @@ -200,10 +373,93 @@ LRESULT PlatformWin32::WndProc(const HWND hwnd, const UINT msg, const WPARAM wPa break; case WM_KEYDOWN: - if (wParam == VK_ESCAPE) + // Bit 30 of lParam is the previous key state: ignore auto-repeat, or holding + // F1 down would write a state every few milliseconds. + if (wParam == VK_F1 && (static_cast(lParam) & (1u << 30)) == 0) + { + platform->SaveStateToStatesDirectory(); + return 0; + } + else if (wParam == VK_SPACE) + { + Config::SetCurrent(Config::MAIN_EMULATION_SPEED, FAST_FORWARD_SPEED); + return 0; + } + else if (wParam == VK_F11) + { + platform->ToggleFullscreen(); + return 0; + } + else if (wParam == VK_ESCAPE && platform->m_fullscreen) + { + platform->ToggleFullscreen(); + return 0; + } + else if (wParam == VK_ESCAPE) + { platform->RequestShutdown(); + } + break; + + case WM_KEYUP: + if (wParam == VK_SPACE) + { + Config::SetCurrent(Config::MAIN_EMULATION_SPEED, 1.0f); + return 0; + } break; + case WM_KILLFOCUS: + // Never leave emulation running fast because Space was released while + // another window had focus and the key-up went elsewhere. + Config::SetCurrent(Config::MAIN_EMULATION_SPEED, 1.0f); + break; + + case WM_SYSKEYDOWN: + if (wParam == VK_RETURN && (GetKeyState(VK_MENU) & 0x8000) != 0) + { + platform->ToggleFullscreen(); + return 0; + } + return DefWindowProc(hwnd, msg, wParam, lParam); + + case WM_INITMENUPOPUP: + if (platform) + platform->RefreshMenu(reinterpret_cast(wParam)); + break; + + case WM_COMMAND: + { + if (!platform) + break; + const UINT command = LOWORD(wParam); + auto& system = Core::System::GetInstance(); + if (command == ID_SAVE_STATE) + { + platform->SaveStateToStatesDirectory(); + } + else if (command == ID_PAUSE) + { + const bool paused = Core::GetState(system) == Core::State::Paused; + Core::SetState(system, paused ? Core::State::Running : Core::State::Paused); + } + else if (command == ID_MUTE) + { + Config::SetCurrent(Config::MAIN_AUDIO_MUTED, !Config::Get(Config::MAIN_AUDIO_MUTED)); + } + else if (command == ID_FULLSCREEN) + { + platform->ToggleFullscreen(); + } + else if (command >= ID_LOAD_STATE_FIRST && command <= ID_LOAD_STATE_LAST) + { + const std::size_t index = command - ID_LOAD_STATE_FIRST; + if (index < platform->m_load_state_paths.size()) + State::LoadAs(system, platform->m_load_state_paths[index]); + } + break; + } + case WM_CLOSE: platform->RequestShutdown(); break; From 26d4fcc9d2e5c107945c069d34dac675cd066581 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Wed, 5 Aug 2026 14:50:49 -1000 Subject: [PATCH 3/3] Define the savestate layout once, and test it there Where savestates live, what they are called, and what order they list in are facts more than one thing has to agree about: the emulator writes and lists them from its own menu, and a frontend offers the same set before boot. Stated twice they drift, and the symptom is the same directory coming back in a different order depending on where you look -- confusing to chase, and easy to cause by editing one copy. Core/SavestateLayout.h holds the extension, the automatic-state prefix, the timestamped name, and the newest-first-with-filename-tiebreak order. It depends on nothing but the standard library, so a frontend can include it without linking any of Dolphin. The menu added in this branch now takes all four from it rather than restating them. The rules are tested where they are defined -- eight cases, including that pruning never removes a player's own saves, that slot saves in the same directory are not listed, and that equal timestamps fall back to filename so the order cannot shuffle between reads. --- Source/Core/Core/SavestateLayout.h | 135 +++++++++++++++ Source/Core/DolphinNoGUI/PlatformWin32.cpp | 37 +---- Source/UnitTests/Core/CMakeLists.txt | 1 + Source/UnitTests/Core/SavestateLayoutTest.cpp | 156 ++++++++++++++++++ 4 files changed, 299 insertions(+), 30 deletions(-) create mode 100644 Source/Core/Core/SavestateLayout.h create mode 100644 Source/UnitTests/Core/SavestateLayoutTest.cpp diff --git a/Source/Core/Core/SavestateLayout.h b/Source/Core/Core/SavestateLayout.h new file mode 100644 index 0000000000..722414ebee --- /dev/null +++ b/Source/Core/Core/SavestateLayout.h @@ -0,0 +1,135 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +// Where savestates live, what they are called, and what order they are listed +// in. One definition, because more than one thing needs to agree about it: the +// emulator writes and lists them from its own menu, and a frontend launching a +// game offers the same set before boot. When those disagree the same states come +// back in a different order depending on where you look, which is a confusing +// bug to chase and an easy one to introduce by editing only one copy. +// +// Deliberately depends on nothing but the standard library. A frontend should be +// able to include this without linking any of Dolphin. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace State::Layout +{ +namespace fs = std::filesystem; + +// Savestates carry this extension. Dolphin's numbered slot saves (.s01 and +// friends) live in the same directory and are deliberately not matched: they are +// managed by slot, not by name, and listing them here would mix two schemes. +inline constexpr std::string_view EXTENSION = ".sav"; + +// States written on a timer or at a checkpoint are told apart from ones a player +// asked for by a filename prefix rather than by a separate directory, so a single +// listing pass returns both and the two cannot get out of step. A game may append +// whatever its own trigger is called -- room, chapter, checkpoint. +inline constexpr std::string_view AUTOMATIC_PREFIX = "recovery-"; + +// Prefix for a state the player asked for. +inline constexpr std::string_view MANUAL_PREFIX = "state-"; + +inline std::tm LocalTime(std::time_t when) +{ + std::tm out{}; +#if defined(_WIN32) + localtime_s(&out, &when); +#else + localtime_r(&when, &out); +#endif + return out; +} + +// Named by wall clock rather than by slot, so repeated saves accumulate instead +// of overwriting one another, and so name order matches time order. +inline std::string TimestampedName(std::time_t when, + std::string_view prefix = MANUAL_PREFIX) +{ + const std::tm local = LocalTime(when); + char stamp[32] = {}; + std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &local); + return std::string(prefix) + stamp + std::string(EXTENSION); +} + +// Newest first: the state wanted next is nearly always the one just written. +// Filename breaks ties so the order is stable when two states share a write +// time, which happens on filesystems with coarse timestamp granularity -- without +// it the same directory can list differently on consecutive reads. +inline bool NewestFirst(const fs::path& left, const fs::path& right) +{ + std::error_code left_ec; + std::error_code right_ec; + const auto left_time = fs::last_write_time(left, left_ec); + const auto right_time = fs::last_write_time(right, right_ec); + if (!left_ec && !right_ec && left_time != right_time) + return left_time > right_time; + return left.filename().string() < right.filename().string(); +} + +// A directory that is not there yields an empty list rather than throwing: +// callers ask before anything has been saved. +inline std::vector List(const fs::path& directory) +{ + std::vector paths; + std::error_code ec; + if (!fs::is_directory(directory, ec)) + return paths; + + for (const fs::directory_entry& entry : fs::directory_iterator(directory, ec)) + { + if (entry.is_regular_file(ec) && entry.path().extension() == EXTENSION) + paths.push_back(entry.path()); + } + std::sort(paths.begin(), paths.end(), NewestFirst); + return paths; +} + +inline std::vector ListAutomatic(const fs::path& directory, + std::string_view prefix = AUTOMATIC_PREFIX) +{ + std::vector paths; + for (const fs::path& path : List(directory)) + { + if (path.filename().string().starts_with(prefix)) + paths.push_back(path); + } + return paths; +} + +inline std::optional LatestAutomatic(const fs::path& directory, + std::string_view prefix = AUTOMATIC_PREFIX) +{ + const std::vector paths = ListAutomatic(directory, prefix); + return paths.empty() ? std::nullopt : std::optional(paths.front()); +} + +// Keeps the newest `keep` automatic states and removes the rest, returning how +// many went. Only prefixed files are ever considered, so a player's own saves +// survive no matter how many automatic ones pile up. +inline std::size_t PruneAutomatic(const fs::path& directory, std::size_t keep, + std::string_view prefix = AUTOMATIC_PREFIX) +{ + const std::vector automatic = ListAutomatic(directory, prefix); + std::error_code ec; + std::size_t removed = 0; + for (std::size_t index = keep; index < automatic.size(); ++index) + { + ec.clear(); + if (fs::remove(automatic[index], ec)) + ++removed; + } + return removed; +} +} // namespace State::Layout diff --git a/Source/Core/DolphinNoGUI/PlatformWin32.cpp b/Source/Core/DolphinNoGUI/PlatformWin32.cpp index 168415baae..3bd26920f8 100644 --- a/Source/Core/DolphinNoGUI/PlatformWin32.cpp +++ b/Source/Core/DolphinNoGUI/PlatformWin32.cpp @@ -6,6 +6,7 @@ #include "Core/Config/MainSettings.h" #include "Core/Config/ConfigManager.h" #include "Core/Core.h" +#include "Core/SavestateLayout.h" #include "Core/State.h" #include "Core/System.h" @@ -185,27 +186,10 @@ void PlatformWin32::RefreshMenu(const HMENU menu) } m_load_state_paths.clear(); - const std::string directory = File::GetUserPath(D_STATESAVES_IDX); - std::vector states; - std::error_code ec; - for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) - { - if (entry.is_regular_file(ec) && entry.path().extension() == ".sav") - states.push_back(entry.path()); - } - // Newest first: the state wanted next is nearly always the one just written. - // Filename breaks ties so the order does not shuffle between opens on a - // filesystem whose timestamps are coarse. - std::sort(states.begin(), states.end(), - [](const std::filesystem::path& left, const std::filesystem::path& right) { - std::error_code left_ec; - std::error_code right_ec; - const auto left_time = std::filesystem::last_write_time(left, left_ec); - const auto right_time = std::filesystem::last_write_time(right, right_ec); - if (!left_ec && !right_ec && left_time != right_time) - return left_time > right_time; - return left.filename().string() < right.filename().string(); - }); + // Location, extension and order all come from State::Layout, so this menu and + // any frontend listing the same directory cannot disagree. + const std::vector states = + State::Layout::List(File::GetUserPath(D_STATESAVES_IDX)); if (states.empty()) { @@ -225,17 +209,10 @@ void PlatformWin32::RefreshMenu(const HMENU menu) void PlatformWin32::SaveStateToStatesDirectory() { - // Named by wall-clock rather than by slot, so repeated saves accumulate - // instead of overwriting one another, and sort meaningfully by name. - const std::time_t now = std::time(nullptr); - std::tm local{}; - localtime_s(&local, &now); - char stamp[32]; - std::strftime(stamp, sizeof(stamp), "%Y%m%d-%H%M%S", &local); - const std::string directory = File::GetUserPath(D_STATESAVES_IDX); File::CreateFullPath(directory); - State::SaveAs(Core::System::GetInstance(), directory + "state-" + stamp + ".sav"); + State::SaveAs(Core::System::GetInstance(), + directory + State::Layout::TimestampedName(std::time(nullptr))); } void PlatformWin32::ToggleFullscreen() diff --git a/Source/UnitTests/Core/CMakeLists.txt b/Source/UnitTests/Core/CMakeLists.txt index 30aeae4477..41ecf23faa 100644 --- a/Source/UnitTests/Core/CMakeLists.txt +++ b/Source/UnitTests/Core/CMakeLists.txt @@ -1,6 +1,7 @@ add_dolphin_test(MMIOTest MMIOTest.cpp) add_dolphin_test(PageFaultTest PageFaultTest.cpp) add_dolphin_test(CoreTimingTest CoreTimingTest.cpp) +add_dolphin_test(SavestateLayoutTest SavestateLayoutTest.cpp) add_dolphin_test(PatchAllowlistTest PatchAllowlistTest.cpp) add_dolphin_test(DSPAcceleratorTest DSP/DSPAcceleratorTest.cpp) diff --git a/Source/UnitTests/Core/SavestateLayoutTest.cpp b/Source/UnitTests/Core/SavestateLayoutTest.cpp new file mode 100644 index 0000000000..ba97652485 --- /dev/null +++ b/Source/UnitTests/Core/SavestateLayoutTest.cpp @@ -0,0 +1,156 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include +#include +#include +#include + +#include "Core/SavestateLayout.h" + +namespace fs = std::filesystem; + +namespace +{ +class SavestateLayoutTest : public ::testing::Test +{ +protected: + void SetUp() override + { + // Unique per run: a fixed name races when two runs overlap and inherits + // whatever a run that died before cleanup left behind. + m_root = fs::temp_directory_path() / + ("dolphin-savestate-layout-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::error_code ec; + fs::create_directories(m_root, ec); + ASSERT_FALSE(ec); + } + + void TearDown() override + { + std::error_code ec; + fs::remove_all(m_root, ec); + } + + // `age` places the file in the past so ordering is deterministic rather than + // depending on how fast the test runs. + fs::path Touch(const std::string& name, std::chrono::seconds age) + { + const fs::path path = m_root / name; + std::ofstream(path).put('x'); + std::error_code ec; + fs::last_write_time(path, fs::file_time_type::clock::now() - age, ec); + return path; + } + + fs::path m_root; +}; + +TEST_F(SavestateLayoutTest, ListsNewestFirstAndIgnoresOtherFiles) +{ + Touch("state-old.sav", std::chrono::seconds(120)); + Touch("state-new.sav", std::chrono::seconds(0)); + Touch("notes.txt", std::chrono::seconds(0)); + // Dolphin's numbered slot saves share the directory and must stay out. + Touch("GC6E01.s01", std::chrono::seconds(0)); + + const auto states = State::Layout::List(m_root); + ASSERT_EQ(states.size(), 2u); + EXPECT_EQ(states[0].filename().string(), "state-new.sav"); + EXPECT_EQ(states[1].filename().string(), "state-old.sav"); +} + +TEST_F(SavestateLayoutTest, EqualTimestampsFallBackToFilename) +{ + const auto when = std::chrono::seconds(60); + Touch("state-b.sav", when); + Touch("state-a.sav", when); + + const auto states = State::Layout::List(m_root); + ASSERT_EQ(states.size(), 2u); + EXPECT_EQ(states[0].filename().string(), "state-a.sav"); + EXPECT_EQ(states[1].filename().string(), "state-b.sav"); +} + +TEST_F(SavestateLayoutTest, MissingDirectoryIsEmptyNotAnError) +{ + EXPECT_TRUE(State::Layout::List(m_root / "absent").empty()); +} + +TEST_F(SavestateLayoutTest, AutomaticStatesAreSelectedByPrefix) +{ + Touch("state-mine.sav", std::chrono::seconds(0)); + Touch("recovery-001.sav", std::chrono::seconds(90)); + Touch("recovery-002.sav", std::chrono::seconds(30)); + + const auto automatic = State::Layout::ListAutomatic(m_root); + ASSERT_EQ(automatic.size(), 2u); + EXPECT_EQ(automatic[0].filename().string(), "recovery-002.sav"); + + const auto latest = State::Layout::LatestAutomatic(m_root); + ASSERT_TRUE(latest.has_value()); + EXPECT_EQ(latest->filename().string(), "recovery-002.sav"); +} + +TEST_F(SavestateLayoutTest, PruningNeverTouchesPlayerStates) +{ + const auto mine = Touch("state-mine.sav", std::chrono::seconds(0)); + const auto oldest = Touch("recovery-001.sav", std::chrono::seconds(90)); + const auto newest = Touch("recovery-002.sav", std::chrono::seconds(30)); + + EXPECT_EQ(State::Layout::PruneAutomatic(m_root, 1), 1u); + EXPECT_FALSE(fs::exists(oldest)); + EXPECT_TRUE(fs::exists(newest)); + EXPECT_TRUE(fs::exists(mine)); +} + +TEST_F(SavestateLayoutTest, KeepingMoreThanExistRemovesNothing) +{ + Touch("recovery-001.sav", std::chrono::seconds(30)); + EXPECT_EQ(State::Layout::PruneAutomatic(m_root, 10), 0u); +} + +TEST_F(SavestateLayoutTest, ACustomPrefixIgnoresTheDefaultOne) +{ + Touch("recovery-001.sav", std::chrono::seconds(30)); + const auto chapter_old = Touch("chapter-001.sav", std::chrono::seconds(90)); + const auto chapter_new = Touch("chapter-002.sav", std::chrono::seconds(0)); + + const auto chapters = State::Layout::ListAutomatic(m_root, "chapter-"); + ASSERT_EQ(chapters.size(), 2u); + EXPECT_EQ(chapters[0].filename().string(), "chapter-002.sav"); + + EXPECT_EQ(State::Layout::PruneAutomatic(m_root, 1, "chapter-"), 1u); + EXPECT_FALSE(fs::exists(chapter_old)); + EXPECT_TRUE(fs::exists(chapter_new)); + // The default-prefixed state belongs to somebody else and must survive. + EXPECT_TRUE(fs::exists(m_root / "recovery-001.sav")); +} + +TEST_F(SavestateLayoutTest, TimestampedNameSortsInTimeOrder) +{ + // 2026-08-05 13:15:02 and one minute later, built explicitly so the test does + // not depend on the clock. + std::tm earlier{}; + earlier.tm_year = 126; + earlier.tm_mon = 7; + earlier.tm_mday = 5; + earlier.tm_hour = 13; + earlier.tm_min = 15; + earlier.tm_sec = 2; + earlier.tm_isdst = -1; + std::tm later = earlier; + later.tm_min = 16; + + const std::string first = State::Layout::TimestampedName(std::mktime(&earlier)); + const std::string second = State::Layout::TimestampedName(std::mktime(&later)); + + EXPECT_EQ(first, "state-20260805-131502.sav"); + EXPECT_LT(first, second); + EXPECT_TRUE(first.starts_with(State::Layout::MANUAL_PREFIX)); + EXPECT_TRUE(first.ends_with(State::Layout::EXTENSION)); +} +} // namespace