aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-18 12:40:18 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-18 12:40:18 -0600
commit276bf2fac226b5ac3059120edc4dfb55578ca601 (patch)
treecec603a7cb09ff99e0bc13dbb6add76c7366f02e
parent3b0bcc514a15a3d6b6d31423cf42b6c43e85bf1c (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.rs2
-rw-r--r--src/src/organizercore.cpp93
-rw-r--r--src/src/wineprefix.cpp144
-rw-r--r--src/src/wineprefix.h9
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;