From 492b32bf3d4410a7dab629315e0ebd8f9346f6b4 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Fri, 8 May 2026 17:33:14 -0500 Subject: profile: guard plugins.txt sync against crash-time rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/src/organizercore.cpp | 19 ++++++++++++++++++- src/src/wineprefix.cpp | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) 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(absDrop) / static_cast(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 '{}'", -- cgit v1.3.1