diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-08 17:33:14 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-08 17:33:14 -0500 |
| commit | 492b32bf3d4410a7dab629315e0ebd8f9346f6b4 (patch) | |
| tree | 2c709fca611a2dc3c42df391ccc1a27db14ec61a | |
| parent | 227be23a0c141690bc7c27dafb9bcdb67f37075a (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.cpp | 19 | ||||
| -rw-r--r-- | src/src/wineprefix.cpp | 47 |
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 '{}'", |
