From f6d217af748c761269a25de8b8b8931b2e9cd6e7 Mon Sep 17 00:00:00 2001 From: Ken Murdock Date: Wed, 22 Nov 2023 17:09:55 +0000 Subject: [PATCH 1/2] Add named save states widget, disable menu entries for numbered/quick/global save states that don't exist --- src/gui/gui.cc | 64 ++++++++-- src/gui/gui.h | 13 +- src/gui/widgets/named_savestates.cc | 180 ++++++++++++++++++++++++++++ src/gui/widgets/named_savestates.h | 31 +++++ vsprojects/gui/gui.vcxproj | 2 + vsprojects/gui/gui.vcxproj.filters | 6 + 6 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 src/gui/widgets/named_savestates.cc create mode 100644 src/gui/widgets/named_savestates.h diff --git a/src/gui/gui.cc b/src/gui/gui.cc index 6e8479bf8..fcf4f2c07 100644 --- a/src/gui/gui.cc +++ b/src/gui/gui.cc @@ -1065,7 +1065,7 @@ void PCSX::GUI::endFrame() { SaveStates::ProtoFile::dumpSchema(schema); } - static constexpr char globalSaveState[] = "global.sstate"; + const auto globalSaveStateName = fmt::format(f_("global{}"), getSaveStatePostfix()); if (ImGui::BeginMenu(_("Save state slots"))) { if (ImGui::MenuItem(_("Quick-save slot"), "F1")) { @@ -1079,25 +1079,44 @@ void PCSX::GUI::endFrame() { } } + ImGui::Separator(); + ImGui::MenuItem(_("Show named save states"), nullptr, &m_namedSaveStates.m_show); + ImGui::EndMenu(); } - if (ImGui::MenuItem(_("Save global state"))) { - saveSaveState(globalSaveState); - } + if (ImGui::MenuItem(_("Save global state"))) saveSaveState(globalSaveStateName); if (ImGui::BeginMenu(_("Load state slots"))) { - if (ImGui::MenuItem(_("Quick-save slot"), "F2")) loadSaveState(buildSaveStateFilename(0)); + auto saveFilename = buildSaveStateFilename(0); + ImGui::BeginDisabled(!saveStateExists(saveFilename)); + if (ImGui::MenuItem(_("Quick-load slot"), "F2")) loadSaveState(saveFilename); + ImGui::EndDisabled(); for (auto i = 1; i < 10; i++) { const auto str = fmt::format(f_("Slot {}"), i); - if (ImGui::MenuItem(str.c_str())) loadSaveState(buildSaveStateFilename(i)); + saveFilename = buildSaveStateFilename(i); + ImGui::BeginDisabled(!saveStateExists(saveFilename)); + if (ImGui::MenuItem(str.c_str())) loadSaveState(saveFilename); + ImGui::EndDisabled(); + } + + auto namedSaves = m_namedSaveStates.getNamedSaveStates(this); + if (!namedSaves.empty()) { + ImGui::Separator(); + for (auto filenamePair : namedSaves) { + if (ImGui::MenuItem(filenamePair.second.c_str())) { + loadSaveState(filenamePair.first); + } + } } ImGui::EndMenu(); } - if (ImGui::MenuItem(_("Load global state"))) loadSaveState(globalSaveState); + ImGui::BeginDisabled(!saveStateExists(globalSaveStateName)); + if (ImGui::MenuItem(_("Load global state"))) loadSaveState(globalSaveStateName); + ImGui::EndDisabled(); ImGui::Separator(); if (ImGui::MenuItem(_("Open LID"))) { @@ -1469,6 +1488,10 @@ in Configuration->Emulation, restart PCSX-Redux, then try again.)")); m_breakpoints.draw(_("Breakpoints")); } + if (m_namedSaveStates.m_show) { + m_namedSaveStates.draw(this, _("Named Save States")); + } + if (m_memoryObserver.m_show) { m_memoryObserver.draw(_("Memory Observer")); } @@ -2431,24 +2454,31 @@ void PCSX::GUI::magicOpen(const char* pathStr) { g_emulator->m_cdrom->check(); } -std::string PCSX::GUI::buildSaveStateFilename(int i) { +std::string PCSX::GUI::getSaveStatePrefix(bool includeSeparator) { // the ID of the game. Every savestate is marked with the ID of the game it's from. const auto gameID = g_emulator->m_cdrom->getCDRomID(); - // Check if the game has a non-NULL ID or a game hasn't been loaded. Some stuff like PS-X // EXEs don't have proper IDs if (!gameID.empty()) { // For games with an ID of SLUS00213 for example, this will generate a state named // SLUS00213.sstate - return fmt::format("{}.sstate{}", gameID, i); + return fmt::format("{}{}", gameID, includeSeparator ? "_" : ""); } else { // For games without IDs, identify them via filename - const auto& iso = PCSX::g_emulator->m_cdrom->getIso()->getIsoPath().filename(); + const auto& iso = g_emulator->m_cdrom->getIso()->getIsoPath().filename(); const auto lastFile = iso.empty() ? "BIOS" : iso.string(); - return fmt::format("{}.sstate{}", lastFile, i); + return fmt::format("{}{}", lastFile, includeSeparator ? "_" : ""); } } +std::string PCSX::GUI::getSaveStatePostfix() { + return ".sstate"; +} + +std::string PCSX::GUI::buildSaveStateFilename(int i) { + return fmt::format("{}{}{}", getSaveStatePrefix(false), getSaveStatePostfix(), i); +} + void PCSX::GUI::saveSaveState(std::filesystem::path filename) { if (filename.is_relative()) { filename = g_system->getPersistentDir() / filename; @@ -2484,7 +2514,15 @@ void PCSX::GUI::loadSaveState(std::filesystem::path filename) { delete[] buff; if (!error) SaveStates::load(os.str()); -}; +} + +bool PCSX::GUI::saveStateExists(std::filesystem::path filename) { + if (filename.is_relative()) { + filename = g_system->getPersistentDir() / filename; + } + ZReader save(new PosixFile(filename)); + return !save.failed(); +} void PCSX::GUI::byteRateToString(float rate, std::string& str) { if (rate >= 1000000000) { diff --git a/src/gui/gui.h b/src/gui/gui.h index ad531534f..ef4e9226c 100644 --- a/src/gui/gui.h +++ b/src/gui/gui.h @@ -49,6 +49,7 @@ #include "gui/widgets/luaeditor.h" #include "gui/widgets/luainspector.h" #include "gui/widgets/memcard_manager.h" +#include "gui/widgets/named_savestates.h" #include "gui/widgets/pio-cart.h" #include "gui/widgets/registers.h" #include "gui/widgets/shader-editor.h" @@ -98,6 +99,7 @@ class GUI final : public UI { typedef Setting ShowAssembly; typedef Setting ShowDisassembly; typedef Setting ShowBreakpoints; + typedef Setting ShowNamedSaveStates; typedef Setting ShowEvents; typedef Setting ShowHandlers; typedef Setting ShowKernelLog; @@ -146,8 +148,8 @@ class GUI final : public UI { Settings m_openBinaryDialog = {[]() { return _("Open Binary"); }}; Widgets::FileDialog<> m_selectBiosDialog = {[]() { return _("Select BIOS"); }}; Widgets::FileDialog<> m_selectEXP1Dialog = {[]() { return _("Select EXP1"); }}; + Widgets::NamedSaveStates m_namedSaveStates = {settings.get().value}; Widgets::Breakpoints m_breakpoints = {settings.get().value}; Widgets::IsoBrowser m_isoBrowser = {settings.get().value}; @@ -405,9 +408,15 @@ class GUI final : public UI { EventBus::Listener m_listener; std::string buildSaveStateFilename(int i); + bool saveStateExists(std::filesystem::path filename); + + public: void saveSaveState(std::filesystem::path filename); void loadSaveState(std::filesystem::path filename); + std::string getSaveStatePrefix(bool includeSeparator); + static std::string getSaveStatePostfix(); + private: void applyTheme(int theme); void cherryTheme(); void monoTheme(); diff --git a/src/gui/widgets/named_savestates.cc b/src/gui/widgets/named_savestates.cc new file mode 100644 index 000000000..d0ac22bf6 --- /dev/null +++ b/src/gui/widgets/named_savestates.cc @@ -0,0 +1,180 @@ + +#include "gui/widgets/named_savestates.h" + +#include "core/cdrom.h" +#include "core/debug.h" +#include "gui/gui.h" +#include "imgui.h" +#include "imgui_internal.h" + +void PCSX::Widgets::NamedSaveStates::draw(GUI* gui, const char* title) { + ImGui::SetNextWindowPos(ImVec2(520, 30), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_FirstUseEver); + if (!ImGui::Begin(title, &m_show)) { + ImGui::End(); + return; + } + + ImGuiStyle& style = ImGui::GetStyle(); + const float heightSeparator = style.ItemSpacing.y; + const float footerHeight = (heightSeparator * 2 + ImGui::GetTextLineHeightWithSpacing()) * 4; + const float glyphWidth = ImGui::GetFontSize(); + + // Any ImGui::Text() preceeding a ImGui::InputText() will be incorrectly aligned, use this value to awkwardly account for this + // Luckily this value is consistent no matter what the UI main font size is set to + const float verticalAlignAdjust = 3.0f; + + // Create a button for each named save state + ImGui::BeginChild("SaveStatesList", ImVec2(0, -footerHeight), true); + auto saveStates = getNamedSaveStates(gui); + for (const auto& saveStatePair : saveStates) { + // The button is invisible so we can adjust the highlight separately + if (ImGui::InvisibleButton(saveStatePair.second.c_str(), + ImVec2(-FLT_MIN, glyphWidth + style.FramePadding.y * 2), + ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnDoubleClick)) { + // Copy the name to the input box + strcpy_s(m_namedSaveNameString, 128, saveStatePair.second.c_str()); + // Load the save state if it was a double-click + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + loadSaveState(gui, saveStatePair.first); + } + } + // Add a base coloured rect - highlight it if hovering over the button or the current InputText below matches it + bool matches = strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0; + bool hovered = ImGui::IsItemHovered(); + ImGui::GetWindowDrawList()->AddRectFilled( + ImGui::GetCurrentContext()->LastItemData.Rect.Min, ImGui::GetCurrentContext()->LastItemData.Rect.Max, + ImGui::ColorConvertFloat4ToU32( + ImGui::GetStyle() + .Colors[matches ? ImGuiCol_HeaderActive : (hovered ? ImGuiCol_HeaderHovered : ImGuiCol_Header)])); + // Finally the butotn text + ImGui::GetWindowDrawList()->AddText( + ImVec2(ImGui::GetCurrentContext()->LastItemData.Rect.Min.x + style.FramePadding.y, + ImGui::GetCurrentContext()->LastItemData.Rect.Min.y + style.FramePadding.y), + ImGui::GetColorU32(ImGuiCol_Text), saveStatePair.second.c_str()); + } + ImGui::EndChild(); + + // Move the leading text down to align with the following InputText() + float posY = ImGui::GetCursorPosY(); + ImGui::SetCursorPosY(posY + verticalAlignAdjust); + + ImGui::Text(_("Filename: ")); + ImGui::SameLine(); + ImGui::Text(gui->getSaveStatePrefix(true).c_str()); + ImGui::SameLine(0.0f, 0.0f); + + // Restore the vertical value + ImGui::SetCursorPosY(posY); + + // Ensure that we don't add invalid characters to the filename + // This also filters on pasted text + struct TextFilters { + static int FilterNonPathCharacters(ImGuiInputTextCallbackData* data) { + // Filter the core problematic characters for Windows and Linux + // Anything remaining outside of [a-zA-Z0-9._-] is also allowed + switch (data->EventChar) { + case '\\': + case '/': + case '<': + case '>': + case '|': + case '\"': + case ':': + case '?': + case '*': + case 0: + return 1; + } + return 0; + } + }; + + ImGui::InputTextWithHint("##SaveNameInput", "Enter the name of your save state here", m_namedSaveNameString, 128, + ImGuiInputTextFlags_CallbackCharFilter, TextFilters::FilterNonPathCharacters); + ImGui::SameLine(0.0f, 0.0f); + + // Trailing text alignment also needs adjusting, but in the opposite direction + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - verticalAlignAdjust); + ImGui::Text(gui->getSaveStatePostfix().c_str()); + + ImGui::Separator(); + + // Add various buttons based on whether a save state exists with that name + auto found = std::find_if(saveStates.begin(), saveStates.end(), [=](auto saveStatePair) { + return strlen(m_namedSaveNameString) > 0 && strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0; + }); + bool exists = found != saveStates.end(); + + float width = ImGui::GetCurrentContext()->LastItemData.Rect.GetWidth(); + ImVec2 buttonDims = ImVec2(-FLT_MIN, glyphWidth + style.FramePadding.y * 2); + ImVec2 halfDims = ImVec2((width - (style.FramePadding.x * 6.0f)) * 0.5f, buttonDims.y); + + if (!exists) { + if (strlen(m_namedSaveNameString) > 0) { + // The save state doesn't exist, and the name is valid + std::string pathStr = fmt::format("{}{}{}", gui->getSaveStatePrefix(true), m_namedSaveNameString, gui->getSaveStatePostfix()); + std::filesystem::path newPath = pathStr; + if (ImGui::Button(_("Create save"), buttonDims)) { + saveSaveState(gui, newPath); + } + } + } else { + // The save state exists + if (ImGui::Button(_("Overwrite save"), halfDims)) { + saveSaveState(gui, found->first); + } + ImGui::SameLine(); + if (ImGui::Button(_("Load save"), halfDims)) { + loadSaveState(gui, found->first); + } + // Add a deliberate spacer before the delete button + // There is no delete confirmation, and this makes a mis-click less likely to hit it + ImGui::Dummy(buttonDims); + if (ImGui::Button(_("Delete save"), buttonDims)) { + deleteSaveState(found->first); + } + } + + ImGui::End(); +} + +std::vector> PCSX::Widgets::NamedSaveStates::getNamedSaveStates(GUI* gui) { + std::vector> names; + + // Get the filename prefix to use, which follows the typical save state format, with a separator between gameID and name + std::string prefix = gui->getSaveStatePrefix(true); + std::string postfix = gui->getSaveStatePostfix(); + + // Loop the root directory + std::error_code ec; + if (std::filesystem::exists(std::filesystem::current_path(), ec)) { + for (const auto& entry : std::filesystem::directory_iterator(std::filesystem::current_path(), ec)) { + if (entry.is_regular_file()) { + std::string filename = entry.path().filename().string(); + if (filename.find(prefix) == 0 && + filename.rfind(postfix) == filename.length() - postfix.length()) { + std::string niceName = filename.substr(prefix.length(), filename.length() - (prefix.length() + postfix.length())); + names.emplace_back(entry.path(), niceName); + } + } + } + } + + return names; +} + +void PCSX::Widgets::NamedSaveStates::saveSaveState(GUI* gui, std::filesystem::path saveStatePath) { + g_system->log(LogClass::UI, "Saving named save state: %s\n", saveStatePath.filename().string().c_str()); + gui->saveSaveState(saveStatePath); +} + +void PCSX::Widgets::NamedSaveStates::loadSaveState(GUI* gui, std::filesystem::path saveStatePath) { + g_system->log(LogClass::UI, "Loading named save state: %s\n", saveStatePath.filename().string().c_str()); + gui->loadSaveState(saveStatePath); +} + +void PCSX::Widgets::NamedSaveStates::deleteSaveState(std::filesystem::path saveStatePath) { + g_system->log(LogClass::UI, "Deleting named save state: %s\n", saveStatePath.filename().string().c_str()); + std::remove(saveStatePath.string().c_str()); +} diff --git a/src/gui/widgets/named_savestates.h b/src/gui/widgets/named_savestates.h new file mode 100644 index 000000000..ccc1565b0 --- /dev/null +++ b/src/gui/widgets/named_savestates.h @@ -0,0 +1,31 @@ + +#pragma once + +#include +#include +#include + +namespace PCSX { + +class GUI; +namespace Widgets { + +class NamedSaveStates { + public: + NamedSaveStates(bool& show) : m_show(show) {} + void draw(GUI* gui, const char* title); + bool& m_show; + + std::vector> getNamedSaveStates(GUI* gui); + + private: + void saveSaveState(GUI* gui, std::filesystem::path saveStatePath); + void loadSaveState(GUI* gui, std::filesystem::path saveStatePath); + void deleteSaveState(std::filesystem::path saveStatePath); + + char m_namedSaveNameString[128] = ""; +}; + +} // namespace Widgets + +} // namespace PCSX diff --git a/vsprojects/gui/gui.vcxproj b/vsprojects/gui/gui.vcxproj index 432022d09..c695b189c 100644 --- a/vsprojects/gui/gui.vcxproj +++ b/vsprojects/gui/gui.vcxproj @@ -258,6 +258,7 @@ + @@ -290,6 +291,7 @@ + diff --git a/vsprojects/gui/gui.vcxproj.filters b/vsprojects/gui/gui.vcxproj.filters index 98c43ddb0..c8c8d436f 100644 --- a/vsprojects/gui/gui.vcxproj.filters +++ b/vsprojects/gui/gui.vcxproj.filters @@ -94,6 +94,9 @@ Source Files\widgets + + Source Files\widgets + Source Files @@ -189,6 +192,9 @@ Header Files + + Header Files + Header Files From 948bdca7a81e29817ad9ee2a06281fa2df30a9c9 Mon Sep 17 00:00:00 2001 From: Ken Murdock Date: Wed, 22 Nov 2023 18:07:16 +0000 Subject: [PATCH 2/2] Linux build fix --- src/gui/widgets/named_savestates.cc | 16 ++++++++++------ src/gui/widgets/named_savestates.h | 5 ++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/gui/widgets/named_savestates.cc b/src/gui/widgets/named_savestates.cc index d0ac22bf6..8cebeed48 100644 --- a/src/gui/widgets/named_savestates.cc +++ b/src/gui/widgets/named_savestates.cc @@ -33,14 +33,14 @@ void PCSX::Widgets::NamedSaveStates::draw(GUI* gui, const char* title) { ImVec2(-FLT_MIN, glyphWidth + style.FramePadding.y * 2), ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnDoubleClick)) { // Copy the name to the input box - strcpy_s(m_namedSaveNameString, 128, saveStatePair.second.c_str()); + strcpy(m_namedSaveNameString, saveStatePair.second.c_str()); // Load the save state if it was a double-click if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { loadSaveState(gui, saveStatePair.first); } } // Add a base coloured rect - highlight it if hovering over the button or the current InputText below matches it - bool matches = strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0; + bool matches = StringsHelpers::strcasecmp(m_namedSaveNameString, saveStatePair.second.c_str()); bool hovered = ImGui::IsItemHovered(); ImGui::GetWindowDrawList()->AddRectFilled( ImGui::GetCurrentContext()->LastItemData.Rect.Min, ImGui::GetCurrentContext()->LastItemData.Rect.Max, @@ -90,8 +90,8 @@ void PCSX::Widgets::NamedSaveStates::draw(GUI* gui, const char* title) { } }; - ImGui::InputTextWithHint("##SaveNameInput", "Enter the name of your save state here", m_namedSaveNameString, 128, - ImGuiInputTextFlags_CallbackCharFilter, TextFilters::FilterNonPathCharacters); + ImGui::InputTextWithHint("##SaveNameInput", "Enter the name of your save state here", m_namedSaveNameString, + NAMED_SAVE_STATE_LENGTH_MAX, ImGuiInputTextFlags_CallbackCharFilter, TextFilters::FilterNonPathCharacters); ImGui::SameLine(0.0f, 0.0f); // Trailing text alignment also needs adjusting, but in the opposite direction @@ -102,7 +102,8 @@ void PCSX::Widgets::NamedSaveStates::draw(GUI* gui, const char* title) { // Add various buttons based on whether a save state exists with that name auto found = std::find_if(saveStates.begin(), saveStates.end(), [=](auto saveStatePair) { - return strlen(m_namedSaveNameString) > 0 && strcmpi(m_namedSaveNameString, saveStatePair.second.c_str()) == 0; + return strlen(m_namedSaveNameString) > 0 && + StringsHelpers::strcasecmp(m_namedSaveNameString, saveStatePair.second.c_str()); }); bool exists = found != saveStates.end(); @@ -155,7 +156,10 @@ std::vector> PCSX::Widgets::NamedS if (filename.find(prefix) == 0 && filename.rfind(postfix) == filename.length() - postfix.length()) { std::string niceName = filename.substr(prefix.length(), filename.length() - (prefix.length() + postfix.length())); - names.emplace_back(entry.path(), niceName); + // Only support names that fit within the character limit + if (niceName.length() < NAMED_SAVE_STATE_LENGTH_MAX) { + names.emplace_back(entry.path(), niceName); + } } } } diff --git a/src/gui/widgets/named_savestates.h b/src/gui/widgets/named_savestates.h index ccc1565b0..af5e66d62 100644 --- a/src/gui/widgets/named_savestates.h +++ b/src/gui/widgets/named_savestates.h @@ -19,11 +19,14 @@ class NamedSaveStates { std::vector> getNamedSaveStates(GUI* gui); private: + + static constexpr int NAMED_SAVE_STATE_LENGTH_MAX = 128; + void saveSaveState(GUI* gui, std::filesystem::path saveStatePath); void loadSaveState(GUI* gui, std::filesystem::path saveStatePath); void deleteSaveState(std::filesystem::path saveStatePath); - char m_namedSaveNameString[128] = ""; + char m_namedSaveNameString[NAMED_SAVE_STATE_LENGTH_MAX] = ""; }; } // namespace Widgets