aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-08 17:33:14 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-08 17:33:14 -0500
commit492b32bf3d4410a7dab629315e0ebd8f9346f6b4 (patch)
tree2c709fca611a2dc3c42df391ccc1a27db14ec61a
parent227be23a0c141690bc7c27dafb9bcdb67f37075a (diff)
profile: guard plugins.txt sync against crash-time rewrites
Bethesda's engine rewrites Plugins.txt as part of its shutdown sequence. On a clean exit it preserves the active set; on a crash it can serialize a partially cleared set — every plugin name still listed, but most without their leading '*'. The previous syncPluginsBack code copied that file back to the profile faithfully, after which refreshESPList + savePluginList re-derived state and persisted the broken active list. NSPBR observed this as 1170 active plugins collapsing to 54. Two independent guards: - afterRun() now skips the post-launch sync when exitCode != 0. Non-zero is a strong "do not trust the prefix file" signal. - syncPluginsBack() compares starred-line counts in candidate vs profile. Refuses the copy when the candidate would drop the active count by more than 30% relative AND more than 10 plugins absolute. Belt and suspenders for cases where the engine catches its own crash and exits 0 anyway. Both guards apply regardless of the VFS bridge state and protect every launch on every modlist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--src/src/organizercore.cpp19
-rw-r--r--src/src/wineprefix.cpp47
2 files changed, 65 insertions, 1 deletions
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index 449ff75..a6cbd12 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -2879,7 +2879,24 @@ void OrganizerCore::afterRun(const QFileInfo& binary, DWORD exitCode)
break;
}
const QString profilePluginsPath = m_CurrentProfile->getPluginsFileName();
- if (!prefix.syncPluginsBack(profilePluginsPath, dataDirName, wineMech)) {
+ // Only sync the prefix Plugins.txt back to the profile when the game
+ // exited cleanly. Bethesda's engine rewrites Plugins.txt as part of
+ // its shutdown sequence, and on a crash it can serialize a partially
+ // cleared "active" set (file lists every plugin but only a handful
+ // still carry the leading '*'). Trusting that file would propagate
+ // the damage into the profile via refreshESPList + savePluginList.
+ // A non-zero exit code is a strong "do not trust the prefix file"
+ // signal. The active-count guard inside syncPluginsBack is a
+ // belt-and-suspenders backstop for cases where the engine catches
+ // its own crash and exits 0 anyway.
+ if (exitCode != 0) {
+ log::warn(
+ "Skipping plugins.txt sync-back: game exited with code {} "
+ "(non-zero / abnormal). Prefix Plugins.txt may have been "
+ "rewritten with a crash-time active set; profile preserved.",
+ exitCode);
+ } else 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 09ab825..4651cef 100644
--- a/src/src/wineprefix.cpp
+++ b/src/src/wineprefix.cpp
@@ -663,6 +663,53 @@ bool WinePrefix::syncPluginsBack(const QString& profilePluginsPath,
}
}
+ // Active-plugin count guard. Bethesda games rewrite Plugins.txt as part
+ // of normal shutdown, but on a crash (e.g. a buggy SKSE plugin going down
+ // mid-frame) the engine can write the file with the active set partially
+ // cleared — every plugin name still listed, but most without their leading
+ // '*'. A naive copy-back propagates that damage into the profile, where
+ // refreshESPList re-derives state from disk and savePluginList persists
+ // the broken active list. External edits (LOOT reordering) never drop the
+ // active count materially, so a large drop is a strong signal that this
+ // is a crash artifact, not a legitimate user-edit. Refuse the copy in
+ // that case and let the profile's existing list stand.
+ auto countStarredLines = [](const QString& path) -> int {
+ QFile f(path);
+ if (!f.open(QIODevice::ReadOnly)) {
+ return -1;
+ }
+ int count = 0;
+ while (!f.atEnd()) {
+ const QByteArray line = f.readLine();
+ // Skip leading whitespace to be tolerant of formatting quirks.
+ int i = 0;
+ while (i < line.size() &&
+ (line[i] == ' ' || line[i] == '\t')) {
+ ++i;
+ }
+ if (i < line.size() && line[i] == '*') {
+ ++count;
+ }
+ }
+ return count;
+ };
+
+ const int profileStars = countStarredLines(profilePluginsPath);
+ const int candidateStars = countStarredLines(newest);
+ if (profileStars > 10 && candidateStars >= 0) {
+ const int absDrop = profileStars - candidateStars;
+ const double relDrop =
+ static_cast<double>(absDrop) / static_cast<double>(profileStars);
+ if (absDrop > 10 && relDrop > 0.30) {
+ MOBase::log::warn(
+ "syncPluginsBack: refusing copy — active plugin count would drop "
+ "from {} to {} ({:.0f}% loss). Likely a game-crash artifact, not "
+ "a real edit. Profile preserved; prefix file left in place at '{}'.",
+ profileStars, candidateStars, relDrop * 100.0, newest);
+ return true;
+ }
+ }
+
MOBase::log::info("syncPluginsBack: '{}' <- '{}'", profilePluginsPath, newest);
if (!copyFileWithParents(newest, profilePluginsPath)) {
MOBase::log::error("syncPluginsBack: failed to copy plugins.txt back to '{}'",