diff --git a/UnleashedRecomp/locale/locale.cpp b/UnleashedRecomp/locale/locale.cpp index 7544f31cf..4cd228f57 100644 --- a/UnleashedRecomp/locale/locale.cpp +++ b/UnleashedRecomp/locale/locale.cpp @@ -347,6 +347,13 @@ std::unordered_map> { ELanguage::Italian, "Il contenuto verrĂ  installato nella cartella di questo programma.\n\n" } } }, + { + // Notes: %s is the install location chosen by the user. + "Installer_Page_CheckSpaceCustom", + { + { ELanguage::English, "The content will be installed to:\n%s\n\n" } + } + }, { // Japanese Notes: This localization should include furigana. "Installer_Page_Installing", @@ -484,6 +491,12 @@ std::unordered_map> { ELanguage::Italian, "AGGIUNGI CARTELLA" } } }, + { + "Installer_Button_ChangeLocation", + { + { ELanguage::English, "CHANGE LOCATION" } + } + }, { // Notes: message appears when using the "Add Files" option and choosing any file that is not an Xbox 360 game dump. // Japanese Notes: This localization should include furigana. @@ -497,6 +510,12 @@ std::unordered_map> { ELanguage::Italian, "I seguenti file non sono validi:" } } }, + { + "Installer_Message_InvalidInstallLocation", + { + { ELanguage::English, "The selected location cannot\nbe used for the installation.\n\nPlease make sure that you have\npermission to write to it." } + } + }, { // Notes: message appears in the event there are some invalid files after adding the DLC and moving onto the next step. // Japanese Notes: This localization should include furigana. @@ -714,6 +733,13 @@ std::unordered_map> { ELanguage::Italian, "Impossibile allocare la memoria per il gioco.\n\nAssicurati che:\n\n- Soddisfi i requisiti minimi di sistema (8 GB).\n- Il tuo file di paging sia configurato con almeno 4 o 8 GB di memoria virtuale." } } }, + { + // Notes: %s is the install location saved in the configuration file. + "System_GameInstallPathMissing", + { + { ELanguage::English, "The game's install location was not found:\n\n%s\n\nPlease make sure the drive is connected, or run the game with the --install argument to choose a new location." } + } + }, { "IntegrityCheck_Success", { diff --git a/UnleashedRecomp/main.cpp b/UnleashedRecomp/main.cpp index bb241760c..3e5b7f787 100644 --- a/UnleashedRecomp/main.cpp +++ b/UnleashedRecomp/main.cpp @@ -249,7 +249,7 @@ int main(int argc, char *argv[]) Journal journal; double lastProgressMiB = 0.0; double lastTotalMib = 0.0; - Installer::checkInstallIntegrity(GAME_INSTALL_DIRECTORY, journal, [&]() + Installer::checkInstallIntegrity(GetGamePath(), journal, [&]() { constexpr double MiBDivisor = 1024.0 * 1024.0; constexpr double MiBProgressThreshold = 128.0; @@ -324,6 +324,15 @@ int main(int argc, char *argv[]) HostStartup(); + if (!forceInstaller && !std::string(Config::GameInstallPath).empty() && !std::filesystem::exists(GetGamePath())) + { + char text[1024]; + std::u8string installPathU8 = GetGamePath().u8string(); + snprintf(text, sizeof(text), Localise("System_GameInstallPathMissing").c_str(), (const char*)installPathU8.c_str()); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, GameWindow::GetTitle(), text, GameWindow::s_pWindow); + std::_Exit(1); + } + std::filesystem::path modulePath; bool isGameInstalled = Installer::checkGameInstall(GetGamePath(), modulePath); bool runInstallerWizard = forceInstaller || forceDLCInstaller || !isGameInstalled; @@ -339,6 +348,9 @@ int main(int argc, char *argv[]) { std::_Exit(0); } + + // The install location may have been changed by the installer. + Installer::checkGameInstall(GetGamePath(), modulePath); } ModLoader::Init(); diff --git a/UnleashedRecomp/mod/mod_loader.cpp b/UnleashedRecomp/mod/mod_loader.cpp index 804cc2f09..3903df98c 100644 --- a/UnleashedRecomp/mod/mod_loader.cpp +++ b/UnleashedRecomp/mod/mod_loader.cpp @@ -100,7 +100,7 @@ void ModLoader::Init() { configIni = {}; - if (!configIni.read(GetGamePath() / "cpkredir.ini")) + if (!configIni.read(GetDefaultGamePath() / "cpkredir.ini")) return; } diff --git a/UnleashedRecomp/ui/installer_wizard.cpp b/UnleashedRecomp/ui/installer_wizard.cpp index a9d060ed1..8c017810c 100644 --- a/UnleashedRecomp/ui/installer_wizard.cpp +++ b/UnleashedRecomp/ui/installer_wizard.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +103,7 @@ static bool g_isDisappearing = false; static bool g_isQuitting = false; static std::filesystem::path g_installPath; +static bool g_installPathCustom = false; static std::filesystem::path g_gameSourcePath; static std::filesystem::path g_updateSourcePath; static std::array g_dlcSourcePaths; @@ -158,6 +160,7 @@ static bool g_pickerTutorialTriggered = false; static bool g_pickerTutorialFolderMode = false; static bool g_currentPickerVisible = false; static bool g_currentPickerFolderMode = false; +static bool g_currentPickerInstallLocation = false; static int g_currentMessageResult = -1; static ImVec2 g_joypadAxis = {}; static int g_currentCursorIndex = -1; @@ -413,6 +416,33 @@ static int DLCIndex(DLC dlc) return (int)(dlc) - 1; } +static void SetInstallPath(const std::filesystem::path &path) +{ + const std::filesystem::path defaultPath = GetDefaultGamePath(); + std::error_code ec; + g_installPath = path; + g_installPathCustom = path != defaultPath && !std::filesystem::equivalent(path, defaultPath, ec); +} + +static std::string GetInstallPathText() +{ + std::u8string pathU8 = g_installPath.u8string(); + std::string text(pathU8.begin(), pathU8.end()); + + constexpr size_t MaxLength = 32; + if (text.length() > MaxLength) + { + // Keep the end of the path and avoid cutting a UTF-8 character in half. + size_t start = text.length() - (MaxLength - 3); + while (start < text.length() && (text[start] & 0xC0) == 0x80) + start++; + + text = "..." + text.substr(start); + } + + return text; +} + static double ComputeMotionInstaller(double timeAppear, double timeDisappear, double offset, double total) { return ComputeMotion(timeAppear, offset, total) * (1.0 - ComputeMotion(timeDisappear, ALL_ANIMATIONS_FULL_DURATION - offset - total, total)); @@ -732,7 +762,16 @@ static void DrawDescriptionContainer() double availableGiB = double(g_installerAvailableSize) / DivisorGiB; snprintf(requiredSpaceText, sizeof(requiredSpaceText), Localise("Installer_Step_RequiredSpace").c_str(), requiredGiB); snprintf(availableSpaceText, sizeof(availableSpaceText), (g_installerAvailableSize > 0) ? Localise("Installer_Step_AvailableSpace").c_str() : "", availableGiB); - snprintf(descriptionText, sizeof(descriptionText), "%s%s\n%s", GetWizardText(g_currentPage).c_str(), requiredSpaceText, availableSpaceText); + + std::string pageText = GetWizardText(g_currentPage); + if (g_installPathCustom) + { + char customPageText[256]; + snprintf(customPageText, sizeof(customPageText), Localise("Installer_Page_CheckSpaceCustom").c_str(), GetInstallPathText().c_str()); + pageText = customPageText; + } + + snprintf(descriptionText, sizeof(descriptionText), "%s%s\n%s", pageText.c_str(), requiredSpaceText, availableSpaceText); } else if (g_currentPage == WizardPage::InstallFailed) { @@ -1109,6 +1148,24 @@ static bool ConvertPathSet(const nfdpathset_t *pathSet, std::listjoin(); @@ -1142,6 +1199,7 @@ static void PickerStart(bool folderMode) { g_currentPickerResults.clear(); g_currentPickerFolderMode = folderMode; + g_currentPickerInstallLocation = installLocation; g_currentPickerResultsReady = false; g_currentPickerVisible = true; @@ -1172,6 +1230,58 @@ static void PickerShow(bool folderMode) } } +static void UpdateAvailableSpace() +{ + std::error_code ec; + std::filesystem::space_info spaceInfo = std::filesystem::space(g_installPath, ec); + g_installerAvailableSize = ec ? 0 : spaceInfo.available; +} + +static bool IsDirectoryWritable(const std::filesystem::path &directory) +{ + const std::filesystem::path testFile = directory / ".unleashedrecomp_write_test"; + + { + std::ofstream stream(testFile, std::ios::binary); + if (!stream.is_open()) + return false; + } + + std::error_code ec; + std::filesystem::remove(testFile, ec); + return true; +} + +static void ChangeInstallLocation(const std::filesystem::path &path) +{ + std::error_code ec; + if (!std::filesystem::is_directory(path, ec) || !IsDirectoryWritable(path)) + { + g_currentMessagePrompt = Localise("Installer_Message_InvalidInstallLocation"); + g_currentMessagePromptConfirmation = false; + return; + } + + SetInstallPath(path); + UpdateAvailableSpace(); +} + +static void SaveInstallPath() +{ + std::string installPath; + if (g_installPathCustom) + { + std::u8string pathU8 = g_installPath.generic_u8string(); + installPath.assign(pathU8.begin(), pathU8.end()); + } + + if (std::string(Config::GameInstallPath) != installPath) + { + Config::GameInstallPath = installPath; + Config::Save(); + } +} + static bool ParseSourcePaths(std::list &paths) { assert((g_currentPage == WizardPage::SelectGameAndUpdate) || (g_currentPage == WizardPage::SelectDLC)); @@ -1294,6 +1404,21 @@ static void DrawSourcePickers() PickerShow(true); } } + else if (g_currentPage == WizardPage::CheckSpace && !g_gameSourcePath.empty()) + { + // The location can't be changed when only adding DLC to an existing installation. + constexpr float CHANGE_BUTTON_MAX_TEXT_WIDTH = 168.0f; + const std::string &changeLocationText = Localise("Installer_Button_ChangeLocation"); + float squashRatio; + ImVec2 textSize = ComputeTextSize(g_dfsogeistdFont, changeLocationText.c_str(), 20.0f, squashRatio, CHANGE_BUTTON_MAX_TEXT_WIDTH); + ImVec2 min = { g_aspectRatioOffsetX + Scale(CONTAINER_X + BOTTOM_X_GAP), g_aspectRatioOffsetY + Scale(CONTAINER_Y + CONTAINER_HEIGHT + BOTTOM_Y_GAP) }; + ImVec2 max = { g_aspectRatioOffsetX + Scale(CONTAINER_X + BOTTOM_X_GAP + textSize.x * squashRatio + BUTTON_TEXT_GAP), g_aspectRatioOffsetY + Scale(CONTAINER_Y + CONTAINER_HEIGHT + BOTTOM_Y_GAP + BUTTON_HEIGHT) }; + DrawButton(min, max, changeLocationText.c_str(), false, true, buttonPressed, CHANGE_BUTTON_MAX_TEXT_WIDTH); + if (buttonPressed) + { + PickerStart(true, true); + } + } } static void DrawSources() @@ -1328,6 +1453,9 @@ static void DrawInstallingProgress() g_installerThread.reset(); g_installerEndTime = ImGui::GetTime(); g_currentPage = g_installerFailed ? WizardPage::InstallFailed : WizardPage::InstallSucceeded; + + if (!g_installerFailed) + SaveInstallPath(); } } } @@ -1369,12 +1497,7 @@ static void InstallerStart() static bool InstallerParseSources(std::string &errorMessage) { - std::error_code spaceErrorCode; - std::filesystem::space_info spaceInfo = std::filesystem::space(g_installPath, spaceErrorCode); - if (!spaceErrorCode) - { - g_installerAvailableSize = spaceInfo.available; - } + UpdateAvailableSpace(); Installer::Input installerInput; installerInput.gameSource = g_gameSourcePath; @@ -1716,9 +1839,16 @@ static void PickerCheckResults() g_currentPickerErrorMessage.clear(); } - if (!g_currentPickerResults.empty() && ParseSourcePaths(g_currentPickerResults)) + if (!g_currentPickerResults.empty()) { - g_pickerTutorialCleared[g_pickerTutorialFolderMode] = true; + if (g_currentPickerInstallLocation) + { + ChangeInstallLocation(g_currentPickerResults.front()); + } + else if (ParseSourcePaths(g_currentPickerResults)) + { + g_pickerTutorialCleared[g_pickerTutorialFolderMode] = true; + } } g_currentPickerResultsReady = false; @@ -1846,7 +1976,11 @@ void InstallerWizard::Shutdown() bool InstallerWizard::Run(std::filesystem::path installPath, bool skipGame) { - g_installPath = installPath; + // Use the default location if the saved one is not available. + if (!std::filesystem::exists(installPath)) + installPath = GetDefaultGamePath(); + + SetInstallPath(installPath); EmbeddedPlayer::Init(); NFD_Init(); diff --git a/UnleashedRecomp/user/config_def.h b/UnleashedRecomp/user/config_def.h index 80eb29c93..49e9542a7 100644 --- a/UnleashedRecomp/user/config_def.h +++ b/UnleashedRecomp/user/config_def.h @@ -9,6 +9,7 @@ CONFIG_DEFINE_LOCALISED("System", bool, ControlTutorial, true); CONFIG_DEFINE_LOCALISED("System", bool, AchievementNotifications, true); CONFIG_DEFINE_ENUM_LOCALISED("System", ETimeOfDayTransition, TimeOfDayTransition, ETimeOfDayTransition::Xbox); CONFIG_DEFINE("System", bool, ShowConsole, false); +CONFIG_DEFINE("System", std::string, GameInstallPath, ""); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, HorizontalCamera, ECameraRotationMode::Normal); CONFIG_DEFINE_ENUM_LOCALISED("Input", ECameraRotationMode, VerticalCamera, ECameraRotationMode::Normal); diff --git a/UnleashedRecomp/user/paths.cpp b/UnleashedRecomp/user/paths.cpp index e3dbf101a..b84f6b5d9 100644 --- a/UnleashedRecomp/user/paths.cpp +++ b/UnleashedRecomp/user/paths.cpp @@ -1,5 +1,6 @@ #include "paths.h" #include +#include std::filesystem::path g_executableRoot = os::process::GetExecutableRoot(); std::filesystem::path g_userPath = BuildUserPath(); @@ -56,3 +57,13 @@ const std::filesystem::path& GetUserPath() { return g_userPath; } + +std::filesystem::path GetGamePath() +{ + const std::string installPath = Config::GameInstallPath; + + if (!installPath.empty()) + return std::u8string_view((const char8_t*)installPath.c_str()); + + return GetDefaultGamePath(); +} diff --git a/UnleashedRecomp/user/paths.h b/UnleashedRecomp/user/paths.h index ecb3e042c..40d78283a 100644 --- a/UnleashedRecomp/user/paths.h +++ b/UnleashedRecomp/user/paths.h @@ -14,7 +14,7 @@ bool CheckPortable(); std::filesystem::path BuildUserPath(); const std::filesystem::path& GetUserPath(); -inline std::filesystem::path GetGamePath() +inline std::filesystem::path GetDefaultGamePath() { #ifdef __APPLE__ // On macOS, there is the expectation that the app may be installed to @@ -26,6 +26,9 @@ inline std::filesystem::path GetGamePath() #endif } +// Returns the install location chosen in the installer, or the default one. +std::filesystem::path GetGamePath(); + inline std::filesystem::path GetSavePath(bool checkForMods) { if (checkForMods && !ModLoader::s_saveFilePath.empty())