diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-18 12:40:18 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-18 12:40:18 -0600 |
| commit | 276bf2fac226b5ac3059120edc4dfb55578ca601 (patch) | |
| tree | cec603a7cb09ff99e0bc13dbb6add76c7366f02e | |
| parent | 3b0bcc514a15a3d6b6d31423cf42b6c43e85bf1c (diff) | |
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 <noreply@anthropic.com>
| -rw-r--r-- | libs/nak/src/installers/symlinks.rs | 2 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 93 | ||||
| -rw-r--r-- | src/src/wineprefix.cpp | 144 | ||||
| -rw-r--r-- | src/src/wineprefix.h | 9 |
4 files changed, 146 insertions, 102 deletions
diff --git a/libs/nak/src/installers/symlinks.rs b/libs/nak/src/installers/symlinks.rs index ef8cbb4..aa55752 100644 --- a/libs/nak/src/installers/symlinks.rs +++ b/libs/nak/src/installers/symlinks.rs @@ -198,7 +198,7 @@ const SKIP_DIRS: &[&str] = &[ "NetHood", "PrintHood", "Recent", "SendTo", "Start Menu", "Templates", "My Documents", "My Music", "My Pictures", "My Videos", "Desktop", "Downloads", - "Favorites", "Links", "Saved Games", "Searches", + "Favorites", "Links", "Searches", "Contacts", "3D Objects", ]; 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 <QMessageBox>
#include <QNetworkInterface>
#include <QProcess>
+#include <QRegularExpression>
#include <QTextStream>
#include <QTimer>
#include <QUrl>
@@ -162,19 +163,22 @@ QString resolvePrefixGameDocumentsDir(const WinePrefix& prefix, return QDir(prefix.myGamesPath()).filePath(dataDirName);
}
-QString resolveSaveRelativePath(std::shared_ptr<Profile> profile,
- const IPluginGame* managedGame,
- MOBase::LocalSavegames* localSaves)
+QString resolveAbsoluteSaveDir(const WinePrefix& prefix,
+ const IPluginGame* managedGame,
+ MOBase::LocalSavegames* localSaves,
+ std::shared_ptr<Profile> 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/<name>/)
+ // 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> 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/<username>/)
+ 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/<dataDirName>/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<LocalSavegames>();
- 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<LocalSavegames>();
- 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 <QDateTime> #include <QDir> #include <QDirIterator> +#include <QSet> #include <QFile> #include <QFileInfo> #include <QTextStream> @@ -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); + // Also restore stale save backups — scan entire prefix for .mo2linux_backup_* + // directories (saves may be in Documents/My Games/.../Saves, Saved Games/..., etc.) + QSet<QString> 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; + } - if (QDir(backupUp).exists() || QDir(backupLow).exists()) { - MOBase::log::info("Restoring stale save backups in '{}'", gameRoot); + 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; + } + + // 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); - // Determine the live save dirs (uppercase "Saves" preferred) - const QString liveUpper = QDir(gameRoot).filePath("Saves"); - const QString liveLower = QDir(gameRoot).filePath("saves"); + 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()); - if (!restoreBackedUpSaves(liveUpper, liveLower, backupUp, backupLow)) { - MOBase::log::warn("Failed to restore stale save backups in '{}'", gameRoot); - } - } + 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<QPair<QString, QString>>& iniMappings) const; |
