From 276bf2fac226b5ac3059120edc4dfb55578ca601 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Wed, 18 Feb 2026 12:40:18 -0600 Subject: Fix save deploy/sync for non-Bethesda games using Saved Games directory Games like Cyberpunk 2077 store saves in %USERPROFILE%/Saved Games/ instead of Documents/My Games/.../Saves. The save pipeline previously hardcoded Documents/My Games as the base path, so profile-specific saves never reached the correct location. Replace relative save path resolution with absolute path resolution that extracts the user-relative portion from LocalSavegames::mappings() or savesDirectory() and reconstructs it under the Fluorine prefix. Update restoreStaleBackups to scan the entire prefix for backup dirs. Remove "Saved Games" from NaK symlink skip list. Fixes #17 Co-Authored-By: Claude Opus 4.6 --- src/src/organizercore.cpp | 93 +++++++++++++++++------------ src/src/wineprefix.cpp | 146 +++++++++++++++++++++++++++------------------- src/src/wineprefix.h | 9 +-- 3 files changed, 146 insertions(+), 102 deletions(-) (limited to 'src') diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index f2b65ec..e1bdda1 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -162,19 +163,22 @@ QString resolvePrefixGameDocumentsDir(const WinePrefix& prefix, return QDir(prefix.myGamesPath()).filePath(dataDirName); } -QString resolveSaveRelativePath(std::shared_ptr profile, - const IPluginGame* managedGame, - MOBase::LocalSavegames* localSaves) +QString resolveAbsoluteSaveDir(const WinePrefix& prefix, + const IPluginGame* managedGame, + MOBase::LocalSavegames* localSaves, + std::shared_ptr profile) { if (profile == nullptr || managedGame == nullptr) { - return "Saves"; + const QString dataDirName = resolveWineDataDirName(managedGame); + return QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); } const QString profileSaveDir = QDir(profile->absolutePath()).filePath("saves"); - const QString gameDocumentsDir = - QDir::cleanPath(managedGame->documentsDirectory().absolutePath()); + // Strategy 1: Use LocalSavegames::mappings() if available. + // Extract the user-relative portion of the destination (after drive_c/users//) + // and reconstruct it under our prefix's userProfilePath(). if (localSaves != nullptr) { const MappingType mappings = localSaves->mappings(QDir(profileSaveDir)); for (const auto& mapping : mappings) { @@ -184,30 +188,46 @@ QString resolveSaveRelativePath(std::shared_ptr profile, const QString source = QDir::cleanPath(mapping.source); const QString destination = QDir::cleanPath(mapping.destination); - if (source == QDir::cleanPath(profileSaveDir) && - destination.startsWith(gameDocumentsDir, Qt::CaseInsensitive)) { - const QString relative = - QDir(gameDocumentsDir).relativeFilePath(destination); - if (!relative.isEmpty() && relative != ".") { - return relative; - } + if (source != QDir::cleanPath(profileSaveDir)) { + continue; } - } - } - const auto iniFiles = managedGame->iniFiles(); - if (iniFiles.isEmpty()) { - return "Saves"; + // Extract the user-relative path (after drive_c/users//) + static const QRegularExpression userDirRe( + "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); + const auto match = userDirRe.match(destination); + if (match.hasMatch()) { + const QString userRelative = match.captured(1); + const QString resolved = + QDir(prefix.userProfilePath()).filePath(userRelative); + log::debug("resolveAbsoluteSaveDir: from mappings -> '{}'", resolved); + return resolved; + } + } } - const QString iniPath = profile->absoluteIniFilePath(iniFiles[0]); - if (!QFileInfo::exists(iniPath)) { - return "Saves"; + // Strategy 2: Use managedGame->savesDirectory() + { + const QDir savesDir = managedGame->savesDirectory(); + const QString savesPath = QDir::cleanPath(savesDir.absolutePath()); + static const QRegularExpression userDirRe( + "drive_c/users/[^/]+/(.+)", QRegularExpression::CaseInsensitiveOption); + const auto match = userDirRe.match(savesPath); + if (match.hasMatch()) { + const QString userRelative = match.captured(1); + const QString resolved = + QDir(prefix.userProfilePath()).filePath(userRelative); + log::debug("resolveAbsoluteSaveDir: from savesDirectory -> '{}'", resolved); + return resolved; + } } - QSettings ini(iniPath, QSettings::IniFormat); - const QString savePath = ini.value("General/SLocalSavePath").toString().trimmed(); - return savePath.isEmpty() ? "Saves" : savePath; + // Fallback: Documents/My Games//Saves (old Bethesda behavior) + const QString dataDirName = resolveWineDataDirName(managedGame); + const QString fallback = + QDir(prefix.myGamesPath()).filePath(dataDirName + "/Saves"); + log::debug("resolveAbsoluteSaveDir: fallback -> '{}'", fallback); + return fallback; } #endif @@ -2182,9 +2202,9 @@ bool OrganizerCore::beforeRun( "Documents/My Games INI dir='{}'", pluginsTargetDir, documentsDir); const auto localSavesFeature = gameFeatures().gameFeature(); - const QString saveRelativePath = - resolveSaveRelativePath(m_CurrentProfile, managedGame(), - localSavesFeature.get()); + const QString absoluteSaveDir = + resolveAbsoluteSaveDir(prefix, managedGame(), + localSavesFeature.get(), m_CurrentProfile); // Read plugin lines from profile's plugins.txt QFile pluginsFile(m_CurrentProfile->getPluginsFileName()); @@ -2245,14 +2265,13 @@ bool OrganizerCore::beforeRun( const QString profileSavesDir = QDir(m_CurrentProfile->absolutePath()).filePath("saves"); log::debug("Resolved local save mapping: profile='{}', target='{}'", - profileSavesDir, saveRelativePath); - if (!prefix.deployProfileSaves(profileSavesDir, dataDirName, saveRelativePath, - true)) { + profileSavesDir, absoluteSaveDir); + if (!prefix.deployProfileSaves(profileSavesDir, absoluteSaveDir, true)) { log::warn("Failed to deploy profile saves from '{}' to prefix '{}'", profileSavesDir, prefixPathStr); } else { - log::debug("Deployed profile saves '{}' -> '{}/{}' in prefix '{}'", - profileSavesDir, dataDirName, saveRelativePath, prefixPathStr); + log::debug("Deployed profile saves '{}' -> '{}' in prefix '{}'", + profileSavesDir, absoluteSaveDir, prefixPathStr); } } } else { @@ -2292,16 +2311,16 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) if (prefix.isValid()) { const QString dataDirName = resolveWineDataDirName(managedGame()); const auto localSavesFeature = gameFeatures().gameFeature(); - const QString saveRelativePath = - resolveSaveRelativePath(m_CurrentProfile, managedGame(), - localSavesFeature.get()); + const QString absoluteSaveDir = + resolveAbsoluteSaveDir(prefix, managedGame(), + localSavesFeature.get(), m_CurrentProfile); if (m_CurrentProfile->localSavesEnabled()) { const QString profileSavesDir = QDir(m_CurrentProfile->absolutePath()).filePath("saves"); log::debug("Syncing local save mapping: profile='{}', target='{}'", - profileSavesDir, saveRelativePath); - if (!prefix.syncSavesBack(profileSavesDir, dataDirName, saveRelativePath)) { + profileSavesDir, absoluteSaveDir); + if (!prefix.syncSavesBack(profileSavesDir, absoluteSaveDir)) { log::warn("Failed to sync saves back from prefix '{}' to '{}'", prefixPathStr, profileSavesDir); } diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp index 6d6f5e0..8ff20e6 100644 --- a/src/src/wineprefix.cpp +++ b/src/src/wineprefix.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,8 +12,6 @@ namespace { -constexpr const char* BackupSavesUpper = ".mo2linux_backup_Saves"; -constexpr const char* BackupSavesLower = ".mo2linux_backup_saves"; constexpr const char* BackupIniSuffix = ".mo2linux_backup"; bool copyFileWithParents(const QString& source, const QString& destination) @@ -132,6 +131,11 @@ QString WinePrefix::appdataLocal() const return QDir(driveC()).filePath("users/steamuser/AppData/Local"); } +QString WinePrefix::userProfilePath() const +{ + return QDir(driveC()).filePath("users/steamuser"); +} + bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDir) const { if (!isValid()) { @@ -264,8 +268,8 @@ bool WinePrefix::deployProfileIni(const QString& sourceIniPath, return true; } -bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, const QString& gameName, - const QString& saveRelativePath, +bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, + const QString& absoluteSaveDir, bool clearDestination) const { if (!isValid()) { @@ -273,38 +277,40 @@ bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, const QString return false; } - MOBase::log::debug("deployProfileSaves: profileSaveDir='{}', gameName='{}', " - "saveRelativePath='{}', clearDestination={}", - profileSaveDir, gameName, saveRelativePath, clearDestination); - const QString gameRoot = QDir(myGamesPath()).filePath(gameName); - const QString normalizedSavePath = - QString(saveRelativePath).replace('\\', '/').trimmed(); - const QString effectiveSavePath = normalizedSavePath.isEmpty() ? "Saves" : normalizedSavePath; - const QString destinationSavesDirUpper = QDir(gameRoot).filePath(effectiveSavePath); - const QString destinationSavesDirLower = - QDir(gameRoot).filePath(effectiveSavePath.toLower()); - const QString backupUpper = QDir(gameRoot).filePath(BackupSavesUpper); - const QString backupLower = QDir(gameRoot).filePath(BackupSavesLower); + MOBase::log::debug("deployProfileSaves: profileSaveDir='{}', absoluteSaveDir='{}', " + "clearDestination={}", + profileSaveDir, absoluteSaveDir, clearDestination); + + const QFileInfo saveDirInfo(absoluteSaveDir); + const QString parentDir = saveDirInfo.dir().absolutePath(); + const QString leafName = saveDirInfo.fileName(); + const QString backupUpper = + QDir(parentDir).filePath(".mo2linux_backup_" + leafName); + const QString backupLower = + QDir(parentDir).filePath(".mo2linux_backup_" + leafName.toLower()); + const QString lowerSaveDir = + QDir(parentDir).filePath(leafName.toLower()); if (clearDestination) { // Recover from any stale backup left by an interrupted run. if ((QDir(backupUpper).exists() || QDir(backupLower).exists()) && - !restoreBackedUpSaves(destinationSavesDirUpper, destinationSavesDirLower, + !restoreBackedUpSaves(absoluteSaveDir, lowerSaveDir, backupUpper, backupLower)) { return false; } - if (QDir(destinationSavesDirUpper).exists() && - !QDir().rename(destinationSavesDirUpper, backupUpper)) { + if (QDir(absoluteSaveDir).exists() && + !QDir().rename(absoluteSaveDir, backupUpper)) { return false; } - if (QDir(destinationSavesDirLower).exists() && - !QDir().rename(destinationSavesDirLower, backupLower)) { + if (absoluteSaveDir != lowerSaveDir && + QDir(lowerSaveDir).exists() && + !QDir().rename(lowerSaveDir, backupLower)) { return false; } } - if (!QDir().mkpath(destinationSavesDirUpper)) { + if (!QDir().mkpath(absoluteSaveDir)) { return false; } @@ -312,32 +318,31 @@ bool WinePrefix::deployProfileSaves(const QString& profileSaveDir, const QString return true; } - return copyTreeContents(profileSaveDir, destinationSavesDirUpper); + return copyTreeContents(profileSaveDir, absoluteSaveDir); } -bool WinePrefix::syncSavesBack(const QString& profileSaveDir, const QString& gameName, - const QString& saveRelativePath) const +bool WinePrefix::syncSavesBack(const QString& profileSaveDir, + const QString& absoluteSaveDir) const { if (!isValid()) { MOBase::log::error("syncSavesBack: prefix '{}' is not valid", m_prefixPath); return false; } - MOBase::log::debug("syncSavesBack: profileSaveDir='{}', gameName='{}', " - "saveRelativePath='{}'", - profileSaveDir, gameName, saveRelativePath); - const QString gameRoot = QDir(myGamesPath()).filePath(gameName); - const QString normalizedSavePath = - QString(saveRelativePath).replace('\\', '/').trimmed(); - const QString effectiveSavePath = normalizedSavePath.isEmpty() ? "Saves" : normalizedSavePath; - const QString upperSaves = QDir(gameRoot).filePath(effectiveSavePath); - const QString lowerSaves = QDir(gameRoot).filePath(effectiveSavePath.toLower()); + MOBase::log::debug("syncSavesBack: profileSaveDir='{}', absoluteSaveDir='{}'", + profileSaveDir, absoluteSaveDir); + + const QFileInfo saveDirInfo(absoluteSaveDir); + const QString parentDir = saveDirInfo.dir().absolutePath(); + const QString leafName = saveDirInfo.fileName(); + const QString lowerSaveDir = + QDir(parentDir).filePath(leafName.toLower()); QString sourceSavesDir; - if (QDir(upperSaves).exists()) { - sourceSavesDir = upperSaves; - } else if (QDir(lowerSaves).exists()) { - sourceSavesDir = lowerSaves; + if (QDir(absoluteSaveDir).exists()) { + sourceSavesDir = absoluteSaveDir; + } else if (absoluteSaveDir != lowerSaveDir && QDir(lowerSaveDir).exists()) { + sourceSavesDir = lowerSaveDir; } else { return true; } @@ -352,10 +357,13 @@ bool WinePrefix::syncSavesBack(const QString& profileSaveDir, const QString& gam profileSaveDir); } - const QString backupUpper = QDir(gameRoot).filePath(BackupSavesUpper); - const QString backupLower = QDir(gameRoot).filePath(BackupSavesLower); - if (!restoreBackedUpSaves(upperSaves, lowerSaves, backupUpper, backupLower)) { - MOBase::log::warn("Failed restoring backed up global saves in '{}'", gameRoot); + const QString backupUpper = + QDir(parentDir).filePath(".mo2linux_backup_" + leafName); + const QString backupLower = + QDir(parentDir).filePath(".mo2linux_backup_" + leafName.toLower()); + if (!restoreBackedUpSaves(absoluteSaveDir, lowerSaveDir, + backupUpper, backupLower)) { + MOBase::log::warn("Failed restoring backed up global saves in '{}'", parentDir); return false; } @@ -387,27 +395,43 @@ void WinePrefix::restoreStaleBackups() const } } - // Also restore stale save backups - const QString myGames = myGamesPath(); - if (QDir(myGames).exists()) { - QDirIterator gameIt(myGames, QDir::Dirs | QDir::NoDotAndDotDot); - while (gameIt.hasNext()) { - gameIt.next(); - const QString gameRoot = gameIt.filePath(); - const QString backupUp = QDir(gameRoot).filePath(BackupSavesUpper); - const QString backupLow = QDir(gameRoot).filePath(BackupSavesLower); - - if (QDir(backupUp).exists() || QDir(backupLow).exists()) { - MOBase::log::info("Restoring stale save backups in '{}'", gameRoot); + // Also restore stale save backups — scan entire prefix for .mo2linux_backup_* + // directories (saves may be in Documents/My Games/.../Saves, Saved Games/..., etc.) + QSet processedBackups; + QDirIterator saveIt(driveC(), QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (saveIt.hasNext()) { + saveIt.next(); + const QString dirName = saveIt.fileName(); + if (!dirName.startsWith(".mo2linux_backup_")) { + continue; + } - // Determine the live save dirs (uppercase "Saves" preferred) - const QString liveUpper = QDir(gameRoot).filePath("Saves"); - const QString liveLower = QDir(gameRoot).filePath("saves"); + const QString backupPath = saveIt.filePath(); + const QString parentDir = QFileInfo(backupPath).dir().absolutePath(); + const QString originalLeaf = dirName.mid(QString(".mo2linux_backup_").length()); + if (originalLeaf.isEmpty()) { + continue; + } - if (!restoreBackedUpSaves(liveUpper, liveLower, backupUp, backupLow)) { - MOBase::log::warn("Failed to restore stale save backups in '{}'", gameRoot); - } - } + // Use lowercase key to deduplicate upper/lower variants in same parent + const QString dedupeKey = parentDir + "/" + originalLeaf.toLower(); + if (processedBackups.contains(dedupeKey)) { + continue; + } + processedBackups.insert(dedupeKey); + + const QString liveUpper = QDir(parentDir).filePath(originalLeaf); + const QString liveLower = QDir(parentDir).filePath(originalLeaf.toLower()); + const QString backupUpper = + QDir(parentDir).filePath(".mo2linux_backup_" + originalLeaf); + const QString backupLower = + QDir(parentDir).filePath(".mo2linux_backup_" + originalLeaf.toLower()); + + MOBase::log::info("Restoring stale save backups: '{}' in '{}'", + originalLeaf, parentDir); + if (!restoreBackedUpSaves(liveUpper, liveLower, backupUpper, backupLower)) { + MOBase::log::warn("Failed to restore stale save backups in '{}'", parentDir); } } } diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h index e1ad839..e195e70 100644 --- a/src/src/wineprefix.h +++ b/src/src/wineprefix.h @@ -16,18 +16,19 @@ public: QString documentsPath() const; // drive_c/users/steamuser/Documents QString myGamesPath() const; // .../Documents/My Games QString appdataLocal() const; // .../AppData/Local + QString userProfilePath() const; // drive_c/users/steamuser // Deploy profile files into prefix bool deployPlugins(const QStringList& plugins, const QString& dataDir) const; bool deployProfileIni(const QString& sourceIniPath, const QString& targetIniPath) const; - bool deployProfileSaves(const QString& profileSaveDir, const QString& gameName, - const QString& saveRelativePath, + bool deployProfileSaves(const QString& profileSaveDir, + const QString& absoluteSaveDir, bool clearDestination) const; // Sync saves back from prefix to profile - bool syncSavesBack(const QString& profileSaveDir, const QString& gameName, - const QString& saveRelativePath) const; + bool syncSavesBack(const QString& profileSaveDir, + const QString& absoluteSaveDir) const; bool syncProfileInisBack( const QList>& iniMappings) const; -- cgit v1.3.1