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", 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 d2e2f2fba5..3bd26920f8 100644 --- a/Source/Core/DolphinNoGUI/PlatformWin32.cpp +++ b/Source/Core/DolphinNoGUI/PlatformWin32.cpp @@ -6,9 +6,19 @@ #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" +#include "Common/CommonPaths.h" +#include "Common/FileUtil.h" + +#include #include +#include +#include +#include +#include #include #include #include @@ -19,6 +29,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 +60,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 +132,123 @@ 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(); + + // 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()) + { + 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() +{ + const std::string directory = File::GetUserPath(D_STATESAVES_IDX); + File::CreateFullPath(directory); + State::SaveAs(Core::System::GetInstance(), + directory + State::Layout::TimestampedName(std::time(nullptr))); +} + +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 +350,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; 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