diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-16 14:24:35 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-16 14:24:35 -0500 |
| commit | 1d323579a0da7c2b22e0208e123907f849c99696 (patch) | |
| tree | 69f758bcb5bdc488d3944d279e1e633383f96540 /src | |
| parent | 7a865a8b757dc8aa5b521fce2d610fa52a86a9f1 (diff) | |
Stop writing loadorder.txt to prefix, propagate save deletions
deployPlugins now writes only the plugin-list file the game actually
reads (Plugins.txt for PluginsTxt games, lowercase plugins.txt for
FileTime games). loadorder.txt is MO2-internal and never belonged in
AppData/Local/<Game>/; previously writing a stale copy there defeated
LOOT's edits because syncPluginsBack pulled the stale loadorder.txt
back into the profile, and getLoadOrder() preferred it over the
freshly-synced plugins.txt.
syncSavesBack now mirrors deletions: files present in the profile but
absent from the prefix are removed before the copy pass. Without this,
saves the user deleted in-game stayed in the profile forever because
copyTreeContents only copies files that exist in the source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/organizercore.cpp | 53 | ||||
| -rw-r--r-- | src/src/wineprefix.cpp | 232 | ||||
| -rw-r--r-- | src/src/wineprefix.h | 31 |
3 files changed, 198 insertions, 118 deletions
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index 6973376..1ec2c87 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -2517,12 +2517,26 @@ bool OrganizerCore::beforeRun( pluginsFile.close();
if (!plugins.isEmpty()) {
- log::info(
- "Deploying plugin list to '{}' and '{}' (load order to '{}')",
- QDir(pluginsTargetDir).filePath("Plugins.txt"),
- QDir(pluginsTargetDir).filePath("plugins.txt"),
- QDir(pluginsTargetDir).filePath("loadorder.txt"));
- if (prefix.deployPlugins(plugins, dataDirName)) {
+ WinePrefix::PluginListMechanism wineMech =
+ WinePrefix::PluginListMechanism::None;
+ switch (managedGame()->loadOrderMechanism()) {
+ case IPluginGame::LoadOrderMechanism::PluginsTxt:
+ wineMech = WinePrefix::PluginListMechanism::PluginsTxt;
+ break;
+ case IPluginGame::LoadOrderMechanism::FileTime:
+ wineMech = WinePrefix::PluginListMechanism::FileTime;
+ break;
+ case IPluginGame::LoadOrderMechanism::None:
+ wineMech = WinePrefix::PluginListMechanism::None;
+ break;
+ }
+ const QString pluginListFile =
+ wineMech == WinePrefix::PluginListMechanism::PluginsTxt
+ ? QString("Plugins.txt")
+ : QString("plugins.txt");
+ log::info("Deploying plugin list to '{}'",
+ QDir(pluginsTargetDir).filePath(pluginListFile));
+ if (prefix.deployPlugins(plugins, dataDirName, wineMech)) {
log::debug("Deployed {} plugins to prefix '{}' (dataDirName='{}')",
plugins.size(), prefixPathStr, dataDirName);
} else {
@@ -2691,17 +2705,28 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode) }
}
- // Sync plugins.txt / loadorder.txt back from the prefix. LOOT and
- // similar tools edit the deployed copies in AppData/Local/<Game>/ —
+ // Sync the game's plugin-list file back from the prefix. LOOT and
+ // similar tools edit the deployed copy in AppData/Local/<Game>/ —
// without this sync, their changes are lost when refreshESPList
// rereads the untouched profile copy and savePluginList clobbers
- // them with the old in-memory order.
+ // them with the old in-memory order. loadorder.txt is MO2-internal
+ // and not written to or read from the prefix.
+ WinePrefix::PluginListMechanism wineMech =
+ WinePrefix::PluginListMechanism::None;
+ switch (managedGame()->loadOrderMechanism()) {
+ case IPluginGame::LoadOrderMechanism::PluginsTxt:
+ wineMech = WinePrefix::PluginListMechanism::PluginsTxt;
+ break;
+ case IPluginGame::LoadOrderMechanism::FileTime:
+ wineMech = WinePrefix::PluginListMechanism::FileTime;
+ break;
+ case IPluginGame::LoadOrderMechanism::None:
+ wineMech = WinePrefix::PluginListMechanism::None;
+ break;
+ }
const QString profilePluginsPath = m_CurrentProfile->getPluginsFileName();
- const QString profileLoadOrderPath =
- m_CurrentProfile->getLoadOrderFileName();
- if (!prefix.syncPluginsBack(profilePluginsPath, profileLoadOrderPath,
- dataDirName)) {
- log::warn("Failed to sync plugins.txt / loadorder.txt back from prefix '{}'",
+ if (!prefix.syncPluginsBack(profilePluginsPath, dataDirName, wineMech)) {
+ log::warn("Failed to sync plugins.txt back from prefix '{}'",
prefixPathStr);
}
}
diff --git a/src/src/wineprefix.cpp b/src/src/wineprefix.cpp index fa1d5e7..5003639 100644 --- a/src/src/wineprefix.cpp +++ b/src/src/wineprefix.cpp @@ -68,6 +68,52 @@ bool copyTreeContents(const QString& sourceRoot, const QString& destinationRoot) return true; } +// Mirror deletions from source into destination: remove any file in the +// destination tree that does not exist at the matching relative path in +// source. Then prune resulting empty directories. Used to propagate +// in-game save deletions from the prefix back to the profile. +bool mirrorDeletions(const QString& sourceRoot, const QString& destinationRoot) +{ + if (!QDir(destinationRoot).exists()) { + return true; + } + + const QDir srcDir(sourceRoot); + QDirIterator it(destinationRoot, QDir::Files | QDir::Hidden | QDir::System, + QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString destFile = it.next(); + const QString relativePath = QDir(destinationRoot).relativeFilePath(destFile); + const QString sourceFile = srcDir.filePath(relativePath); + if (!QFile::exists(sourceFile)) { + if (!QFile::remove(destFile)) { + MOBase::log::warn("mirrorDeletions: failed to remove '{}'", destFile); + } else { + MOBase::log::debug("mirrorDeletions: removed '{}'", destFile); + } + } + } + + // Prune empty directories bottom-up. + QStringList dirs; + QDirIterator dirIt(destinationRoot, + QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden, + QDirIterator::Subdirectories); + while (dirIt.hasNext()) { + dirs.append(dirIt.next()); + } + std::sort(dirs.begin(), dirs.end(), + [](const QString& a, const QString& b) { return a.length() > b.length(); }); + for (const QString& d : dirs) { + QDir qd(d); + if (qd.isEmpty(QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden)) { + qd.rmdir("."); + } + } + + return true; +} + bool restoreBackedUpSaves(const QString& liveUpper, const QString& liveLower, const QString& backupUpper, const QString& backupLower) { @@ -165,7 +211,8 @@ QString WinePrefix::userProfilePath() const return QDir(driveC()).filePath("users/steamuser"); } -bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDir) const +bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDir, + PluginListMechanism mechanism) const { if (!isValid()) { MOBase::log::error("deployPlugins: prefix '{}' is not valid (drive_c not found)", @@ -173,22 +220,28 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi return false; } + if (mechanism == PluginListMechanism::None) { + MOBase::log::debug("deployPlugins: game has no plugin-list mechanism, skipping"); + return true; + } + const QString pluginsDir = QDir(appdataLocal()).filePath(dataDir); - MOBase::log::info("deployPlugins: target dir='{}', count={}", pluginsDir, - plugins.size()); + MOBase::log::info("deployPlugins: target dir='{}', count={}, mechanism={}", + pluginsDir, plugins.size(), + mechanism == PluginListMechanism::PluginsTxt ? "PluginsTxt" + : "FileTime"); if (!QDir().mkpath(pluginsDir)) { MOBase::log::error("deployPlugins: failed to create directory '{}'", pluginsDir); return false; } - // Remove all case variants of plugins.txt and loadorder.txt before writing. - // Linux is case-sensitive, so a stale "plugins.txt" can coexist with - // "Plugins.txt" and the game may read the wrong one (e.g. FalloutNV reads - // lowercase "plugins.txt"). - const QString pluginsPath = QDir(pluginsDir).filePath("Plugins.txt"); - const QString loadOrderPath = QDir(pluginsDir).filePath("loadorder.txt"); - for (const QString& variant : findCaseVariants(pluginsPath)) { + // Clear ALL stale plugin-list files in AppData — any case of "plugins.txt" + // and any "loadorder.txt". loadorder.txt is MO2-internal (profile only); + // leaving a stale one in the prefix confuses sync-back if mechanism changes. + const QString pluginsCanonical = QDir(pluginsDir).filePath("plugins.txt"); + const QString loadOrderPath = QDir(pluginsDir).filePath("loadorder.txt"); + for (const QString& variant : findCaseVariants(pluginsCanonical)) { MOBase::log::debug("deployPlugins: removing stale plugins variant '{}'", variant); QFile::remove(variant); } @@ -197,9 +250,19 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi QFile::remove(variant); } - QFile pluginsFile(pluginsPath); + // PluginsTxt games (SSE/AE, FO4, Starfield, ...) read "Plugins.txt" with + // '*' prefix for enabled. FileTime games (FNV, FO3, Skyrim LE) read + // lowercase "plugins.txt" listing enabled plugins only (no prefix) and + // derive order from file mtimes. Lines are already in the correct game + // format because they come straight from the profile's plugins.txt, which + // MO2 writes per-game via writePluginLists(). + const bool useCapitalP = mechanism == PluginListMechanism::PluginsTxt; + const QString targetPath = + QDir(pluginsDir).filePath(useCapitalP ? "Plugins.txt" : "plugins.txt"); + + QFile pluginsFile(targetPath); if (!pluginsFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { - MOBase::log::error("deployPlugins: failed to open '{}' for writing", pluginsPath); + MOBase::log::error("deployPlugins: failed to open '{}' for writing", targetPath); return false; } @@ -209,35 +272,7 @@ bool WinePrefix::deployPlugins(const QStringList& plugins, const QString& dataDi } pluginsFile.close(); MOBase::log::info("deployPlugins: wrote {} plugins to '{}'", plugins.size(), - pluginsPath); - - // Also write lowercase "plugins.txt" for games that expect it (e.g. FalloutNV). - const QString pluginsLower = QDir(pluginsDir).filePath("plugins.txt"); - if (pluginsLower != pluginsPath) { - QFile::remove(pluginsLower); - if (!QFile::copy(pluginsPath, pluginsLower)) { - MOBase::log::warn("deployPlugins: failed to create lowercase copy '{}'", - pluginsLower); - } - } - - QFile loadOrderFile(loadOrderPath); - if (!loadOrderFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { - MOBase::log::error("deployPlugins: failed to open '{}' for writing", - loadOrderPath); - return false; - } - - QTextStream loadOrderStream(&loadOrderFile); - for (const QString& plugin : plugins) { - QString line = plugin; - if (line.startsWith('*')) { - line.remove(0, 1); - } - - loadOrderStream << line << "\r\n"; - } - MOBase::log::info("deployPlugins: wrote loadorder.txt to '{}'", loadOrderPath); + targetPath); return true; } @@ -385,6 +420,11 @@ bool WinePrefix::syncSavesBack(const QString& profileSaveDir, return false; } + // Mirror deletions first, then copy remaining files. Without the delete + // pass, a save the user deleted in-game would remain in the profile + // because copyTreeContents only copies files present in the source. + mirrorDeletions(sourceSavesDir, profileSaveDir); + const bool copied = copyTreeContents(sourceSavesDir, profileSaveDir); if (!copied) { MOBase::log::warn("Failed syncing saves from '{}' to '{}'", sourceSavesDir, @@ -533,14 +573,18 @@ bool WinePrefix::syncProfileInisBack( } bool WinePrefix::syncPluginsBack(const QString& profilePluginsPath, - const QString& profileLoadOrderPath, - const QString& dataDir) const + const QString& dataDir, + PluginListMechanism mechanism) const { if (!isValid()) { MOBase::log::error("syncPluginsBack: prefix '{}' is not valid", m_prefixPath); return false; } + if (mechanism == PluginListMechanism::None) { + return true; + } + const QString pluginsDir = QDir(appdataLocal()).filePath(dataDir); if (!QDir(pluginsDir).exists()) { MOBase::log::debug("syncPluginsBack: prefix plugins dir '{}' does not exist", @@ -548,66 +592,62 @@ bool WinePrefix::syncPluginsBack(const QString& profilePluginsPath, return true; } - // Pick the newest case variant, sync it to the profile, then mirror its - // content back into every sibling variant in the prefix so they don't - // drift. LOOT (and similar tools) only edit whichever case variant they - // opened; without this mirror the untouched sibling keeps stale content - // until the next deployPlugins, which can confuse anything that reads - // the prefix before then. - auto syncOne = [&](const QString& canonicalName, - const QString& profilePath) -> bool { - const QStringList variants = - findCaseVariants(QDir(pluginsDir).filePath(canonicalName)); - if (variants.isEmpty()) { - MOBase::log::debug("syncPluginsBack: no {} variant found in '{}'", - canonicalName, pluginsDir); - return true; - } + // Pick the newest case variant of the game's plugin-list file, sync it + // to the profile, then mirror that content into every sibling variant in + // the prefix so they don't drift. LOOT edits whichever case it opened; + // without mirroring, the untouched sibling keeps stale content. + // loadorder.txt is MO2-internal — never touched in the prefix and never + // read back; MO2 re-derives it from the synced plugins file. + const QStringList variants = + findCaseVariants(QDir(pluginsDir).filePath("plugins.txt")); + if (variants.isEmpty()) { + MOBase::log::debug("syncPluginsBack: no plugins.txt variant found in '{}'", + pluginsDir); + return true; + } - QString newest; - QDateTime newestTime; - for (const QString& v : variants) { - const QFileInfo fi(v); - if (!newestTime.isValid() || fi.lastModified() > newestTime) { - newestTime = fi.lastModified(); - newest = v; - } + QString newest; + QDateTime newestTime; + for (const QString& v : variants) { + const QFileInfo fi(v); + if (!newestTime.isValid() || fi.lastModified() > newestTime) { + newestTime = fi.lastModified(); + newest = v; } + } - MOBase::log::info("syncPluginsBack: '{}' <- '{}'", profilePath, newest); - if (!copyFileWithParents(newest, profilePath)) { - MOBase::log::error("syncPluginsBack: failed to copy {} back to '{}'", - canonicalName, profilePath); - return false; - } + MOBase::log::info("syncPluginsBack: '{}' <- '{}'", profilePluginsPath, newest); + if (!copyFileWithParents(newest, profilePluginsPath)) { + MOBase::log::error("syncPluginsBack: failed to copy plugins.txt back to '{}'", + profilePluginsPath); + return false; + } - // Mirror newest content into any stale sibling variants so the prefix - // stays consistent regardless of which casing the next reader opens. - for (const QString& sibling : variants) { - if (sibling == newest) { - continue; - } - if (!QFile::remove(sibling)) { - MOBase::log::warn("syncPluginsBack: failed to remove stale sibling '{}'", - sibling); - continue; - } - if (!QFile::copy(newest, sibling)) { - MOBase::log::warn("syncPluginsBack: failed to mirror '{}' -> '{}'", - newest, sibling); - } + for (const QString& sibling : variants) { + if (sibling == newest) { + continue; + } + if (!QFile::remove(sibling)) { + MOBase::log::warn("syncPluginsBack: failed to remove stale sibling '{}'", + sibling); + continue; + } + if (!QFile::copy(newest, sibling)) { + MOBase::log::warn("syncPluginsBack: failed to mirror '{}' -> '{}'", + newest, sibling); } - return true; - }; - - bool ok = true; - if (!syncOne("plugins.txt", profilePluginsPath)) { - ok = false; } - if (!syncOne("loadorder.txt", profileLoadOrderPath)) { - ok = false; + + // Clear any stale loadorder.txt that an older build may have written. + // The game never reads it; leaving it around only invites confusion. + for (const QString& stale : + findCaseVariants(QDir(pluginsDir).filePath("loadorder.txt"))) { + MOBase::log::debug("syncPluginsBack: removing stale loadorder variant '{}'", + stale); + QFile::remove(stale); } - return ok; + + return true; } // ── Wine registry (.reg file) access ───────────────────────────────────────── diff --git a/src/src/wineprefix.h b/src/src/wineprefix.h index b0e54e7..c817f2e 100644 --- a/src/src/wineprefix.h +++ b/src/src/wineprefix.h @@ -9,6 +9,14 @@ class WinePrefix { public: + // Mirrors MOBase::IPluginGame::LoadOrderMechanism without pulling that + // header into wineprefix. Callers translate from the game feature. + enum class PluginListMechanism { + None, // no plugin list file in AppData + FileTime, // lowercase "plugins.txt" (enabled-only), order via mtime + PluginsTxt, // "Plugins.txt" with '*' prefix for enabled (SSE/AE/FO4/Starfield) + }; + explicit WinePrefix(const QString& prefixPath); bool isValid() const; // drive_c/ exists @@ -18,26 +26,33 @@ public: 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; + // Deploy profile files into prefix. Only the plugin list file the game + // actually reads is written — loadorder.txt is MO2-internal and never + // belongs in AppData/Local/<Game>/. + bool deployPlugins(const QStringList& plugins, const QString& dataDir, + PluginListMechanism mechanism) const; bool deployProfileIni(const QString& sourceIniPath, const QString& targetIniPath) const; bool deployProfileSaves(const QString& profileSaveDir, const QString& absoluteSaveDir, bool clearDestination) const; - // Sync saves back from prefix to profile + // Sync saves back from prefix to profile. Mirrors deletions: profile + // files absent from the prefix are removed so in-game save deletions + // propagate. bool syncSavesBack(const QString& profileSaveDir, const QString& absoluteSaveDir) const; bool syncProfileInisBack( const QList<QPair<QString, QString>>& iniMappings) const; - // Sync plugins.txt / loadorder.txt from the prefix AppData back to the - // profile after a tool like LOOT may have edited them. Picks the newest - // case-insensitive variant of each file. + // Sync the game's plugin-list file from the prefix AppData back to the + // profile after a tool like LOOT may have edited it. Picks the newest + // case-insensitive variant. Only the Plugins.txt/plugins.txt file the + // game reads is synced — loadorder.txt is never written to the prefix + // and is not read back. bool syncPluginsBack(const QString& profilePluginsPath, - const QString& profileLoadOrderPath, - const QString& dataDir) const; + const QString& dataDir, + PluginListMechanism mechanism) const; // Restore any stale .mo2linux_backup INI/save files left by a crash. // Should be called at startup before any game runs. |
