aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-23 09:59:01 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-23 09:59:01 -0500
commit7f0ded9e4a6e11b5e0327dd8eb76dd5945b8693d (patch)
treebfbc73c5c4c9f6cc5f196129fe8e0b583ebee639
parentb796e336c4795f5c208368aa09b178d905f46173 (diff)
prefix: align dependency setup and VFS fixes
-rw-r--r--libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp2
-rw-r--r--src/src/fuseconnector.cpp29
-rw-r--r--src/src/prefixsetuprunner.cpp486
-rw-r--r--src/src/prefixsetuprunner.h21
-rw-r--r--src/src/settingsdialogupdates.cpp31
-rw-r--r--src/src/vfs/mo2filesystem.cpp74
-rw-r--r--src/src/vfs/trackedwrites.cpp97
-rw-r--r--src/src/vfs/trackedwrites.h22
-rw-r--r--src/tests/CMakeLists.txt23
-rw-r--r--src/tests/test_overwrite_duplicates.cpp71
10 files changed, 779 insertions, 77 deletions
diff --git a/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp b/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
index 377c65c..aad88c8 100644
--- a/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
+++ b/libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp
@@ -136,7 +136,7 @@ bool GameSkyrimSE::init(IOrganizer* moInfo)
registerFeature(std::make_shared<SkyrimSEScriptExtender>(this));
registerFeature(std::make_shared<SkyrimSEDataArchives>(this));
- registerFeature(std::make_shared<GamebryoLocalSavegames>(this, "Skyrimcustom.ini"));
+ registerFeature(std::make_shared<GamebryoLocalSavegames>(this, "SkyrimCustom.ini"));
registerFeature(std::make_shared<SkyrimSEModDataChecker>(this));
registerFeature(
std::make_shared<SkyrimSEModDataContent>(m_Organizer->gameFeatures()));
diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp
index 12635f3..679ae34 100644
--- a/src/src/fuseconnector.cpp
+++ b/src/src/fuseconnector.cpp
@@ -480,6 +480,35 @@ bool FuseConnector::mount(
// with a mod file get incorrectly tracked. Tracking now only happens
// through explicit user actions (UI move/sync/drag-drop) or the
// snapshot-based detectManualMoves().
+ const auto duplicates =
+ m_trackedWrites->scanOverwriteDuplicates(m_overwriteDir, mods);
+ if (!duplicates.empty()) {
+ size_t identical = 0;
+ size_t different = 0;
+ for (const auto& dup : duplicates) {
+ if (dup.state == TrackedWrites::DuplicateState::Identical) {
+ ++identical;
+ } else {
+ ++different;
+ }
+ }
+
+ std::fprintf(stderr,
+ "[VFS] overwrite duplicate scan: %zu matches (%zu identical, %zu different)\n",
+ duplicates.size(), identical, different);
+ const size_t preview = std::min<size_t>(duplicates.size(), 10);
+ for (size_t i = 0; i < preview; ++i) {
+ const auto& dup = duplicates[i];
+ std::fprintf(stderr,
+ "[VFS] %s -> %s (%s)\n",
+ dup.relative_path.c_str(), dup.mod_name.c_str(),
+ dup.state == TrackedWrites::DuplicateState::Identical ? "identical"
+ : "different");
+ }
+ } else {
+ std::fprintf(stderr, "[VFS] overwrite duplicate scan: no exact path matches\n");
+ }
+
m_trackedWrites->save(m_trackingFilePath);
} else {
std::fprintf(stderr, "[VFS] WARNING: tracking file path is empty!\n");
diff --git a/src/src/prefixsetuprunner.cpp b/src/src/prefixsetuprunner.cpp
index e57a9c9..4684628 100644
--- a/src/src/prefixsetuprunner.cpp
+++ b/src/src/prefixsetuprunner.cpp
@@ -72,6 +72,12 @@ static const char* DIRECTX_JUN2010_SHA256 =
// Visual C++ 2015-2022 redistributable.
static const char* VCRUN2022_X86_URL = "https://aka.ms/vs/17/release/vc_redist.x86.exe";
static const char* VCRUN2022_X64_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe";
+static const QStringList VCRUN2022_X86_SHA256 = {
+ QStringLiteral("0c09f2611660441084ce0df425c51c11e147e6447963c3690f97e0b25c55ed64"),
+};
+static const QStringList VCRUN2022_X64_SHA256 = {
+ QStringLiteral("cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b"),
+};
// .NET runtimes (x86 + x64 pairs for Wine prefix).
static const char* DOTNET6_X86_URL =
@@ -102,6 +108,126 @@ static const char* DOTNET_DESKTOP6_X64_URL =
"https://download.visualstudio.microsoft.com/download/pr/f6b6c5dc-e02d-4738-9559-296e938dabcb/"
"b66d365729359df8e8ea131197715076/windowsdesktop-runtime-6.0.36-win-x64.exe";
+static const QStringList DOTNET_DESKTOP6_X86_SHA256 = {
+ QStringLiteral("4e77bd970df0a06528ee88d33e4a8c9fb85beedbdd7219b017083acf0c3aa39e"),
+};
+static const QStringList DOTNET_DESKTOP6_X64_SHA256 = {
+ QStringLiteral("0d20debb26fc8b2bc84f25fbd9d4596a6364af8517ebf012e8b871127b798941"),
+};
+static const QStringList DOTNET6_X86_SHA256 = {
+ QStringLiteral("3b3cb4636251a582158f4b6b340f20b3861e6793eb9a3e64bda29cbf32da3604"),
+};
+static const QStringList DOTNET6_X64_SHA256 = {
+ QStringLiteral("6bdad7bc4c41fe93d4ae7b0312b1d017cfe369d28e7e2e421f5b675f9feefe84"),
+};
+static const QStringList DOTNET7_X86_SHA256 = {
+ QStringLiteral("9bf79c94ab014b555167e61f3ce653fdf54c70bda6d6c74ab9f6f44652947a89"),
+};
+static const QStringList DOTNET7_X64_SHA256 = {
+ QStringLiteral("10f48feee0f7fb4c2ed61ecef5e58699743afc9531f8a293680a99fc2d0a78a5"),
+};
+static const QStringList DOTNET8_X86_SHA256 = {
+ QStringLiteral("eb0d8f39fa2dbb4ff3ff72ad325b6030773df875ab509824ea18c87a368985fa"),
+};
+static const QStringList DOTNET8_X64_SHA256 = {
+ QStringLiteral("a7c394e6ee4e8104d7a01f78103700052cc504370941b7f620e3aa5afbbc61df"),
+};
+
+static const QStringList VCRUN_DLLS = {
+ QStringLiteral("concrt140"),
+ QStringLiteral("msvcp140"),
+ QStringLiteral("msvcp140_1"),
+ QStringLiteral("msvcp140_2"),
+ QStringLiteral("msvcp140_atomic_wait"),
+ QStringLiteral("msvcp140_codecvt_ids"),
+ QStringLiteral("vcamp140"),
+ QStringLiteral("vccorlib140"),
+ QStringLiteral("vcomp140"),
+ QStringLiteral("vcruntime140"),
+ QStringLiteral("vcruntime140_1"),
+};
+
+static const QStringList DIRECTX_NATIVE_DLLS = {
+ QStringLiteral("d3dcompiler_42"),
+ QStringLiteral("d3dcompiler_43"),
+ QStringLiteral("d3dcompiler_47"),
+ QStringLiteral("d3dx9_24"),
+ QStringLiteral("d3dx9_25"),
+ QStringLiteral("d3dx9_26"),
+ QStringLiteral("d3dx9_27"),
+ QStringLiteral("d3dx9_28"),
+ QStringLiteral("d3dx9_29"),
+ QStringLiteral("d3dx9_30"),
+ QStringLiteral("d3dx9_31"),
+ QStringLiteral("d3dx9_32"),
+ QStringLiteral("d3dx9_33"),
+ QStringLiteral("d3dx9_34"),
+ QStringLiteral("d3dx9_35"),
+ QStringLiteral("d3dx9_36"),
+ QStringLiteral("d3dx9_37"),
+ QStringLiteral("d3dx9_38"),
+ QStringLiteral("d3dx9_39"),
+ QStringLiteral("d3dx9_40"),
+ QStringLiteral("d3dx9_41"),
+ QStringLiteral("d3dx9_42"),
+ QStringLiteral("d3dx9_43"),
+ QStringLiteral("d3dx10_33"),
+ QStringLiteral("d3dx10_34"),
+ QStringLiteral("d3dx10_35"),
+ QStringLiteral("d3dx10_36"),
+ QStringLiteral("d3dx10_37"),
+ QStringLiteral("d3dx10_38"),
+ QStringLiteral("d3dx10_39"),
+ QStringLiteral("d3dx10_40"),
+ QStringLiteral("d3dx10_41"),
+ QStringLiteral("d3dx10_42"),
+ QStringLiteral("d3dx10_43"),
+ QStringLiteral("d3dx11_42"),
+ QStringLiteral("d3dx11_43"),
+};
+
+static const QStringList DIRECTX_NATIVE_BUILTIN_DLLS = {
+ QStringLiteral("xaudio2_0"),
+ QStringLiteral("xaudio2_1"),
+ QStringLiteral("xaudio2_2"),
+ QStringLiteral("xaudio2_3"),
+ QStringLiteral("xaudio2_4"),
+ QStringLiteral("xaudio2_5"),
+ QStringLiteral("xaudio2_6"),
+ QStringLiteral("xaudio2_7"),
+ QStringLiteral("x3daudio1_0"),
+ QStringLiteral("x3daudio1_1"),
+ QStringLiteral("x3daudio1_2"),
+ QStringLiteral("x3daudio1_3"),
+ QStringLiteral("x3daudio1_4"),
+ QStringLiteral("x3daudio1_5"),
+ QStringLiteral("x3daudio1_6"),
+ QStringLiteral("x3daudio1_7"),
+ QStringLiteral("xapofx1_1"),
+ QStringLiteral("xapofx1_2"),
+ QStringLiteral("xapofx1_3"),
+ QStringLiteral("xapofx1_4"),
+ QStringLiteral("xapofx1_5"),
+ QStringLiteral("xactengine2_0"),
+ QStringLiteral("xactengine2_1"),
+ QStringLiteral("xactengine2_2"),
+ QStringLiteral("xactengine2_3"),
+ QStringLiteral("xactengine2_4"),
+ QStringLiteral("xactengine2_5"),
+ QStringLiteral("xactengine2_6"),
+ QStringLiteral("xactengine2_7"),
+ QStringLiteral("xactengine2_8"),
+ QStringLiteral("xactengine2_9"),
+ QStringLiteral("xactengine2_10"),
+ QStringLiteral("xactengine3_0"),
+ QStringLiteral("xactengine3_1"),
+ QStringLiteral("xactengine3_2"),
+ QStringLiteral("xactengine3_3"),
+ QStringLiteral("xactengine3_4"),
+ QStringLiteral("xactengine3_5"),
+ QStringLiteral("xactengine3_6"),
+ QStringLiteral("xactengine3_7"),
+};
// Allowed drive letters to keep in the prefix.
static const QStringList ALLOWED_DRIVES = {"c:", "z:"};
@@ -438,7 +564,12 @@ void PrefixSetupRunner::buildStepList()
addStep("vcrun2022", "Visual C++ 2022",
[this] { return stepVcrun2022(); });
addStep("dotnetdesktop6", ".NET Desktop Runtime 6",
- [this] { return stepDotNetInstallPair(DOTNET_DESKTOP6_X86_URL, DOTNET_DESKTOP6_X64_URL, ".NET Desktop 6"); });
+ [this] {
+ return stepDotNetInstallPair(
+ DOTNET_DESKTOP6_X86_URL, DOTNET_DESKTOP6_X64_URL,
+ ".NET Desktop 6", DOTNET_DESKTOP6_X86_SHA256,
+ DOTNET_DESKTOP6_X64_SHA256);
+ });
addStep("dotnet_runtimes", ".NET Runtimes (6-9)",
[this] { return stepDotNetRuntimes(); });
addStep("dotnet10_sdk", ".NET 10 SDK",
@@ -1084,6 +1215,9 @@ bool PrefixSetupRunner::stepDirectXRuntime()
{
emit logMessage("Installing DirectX runtimes...");
+ if (!applyDllOverrides(DIRECTX_NATIVE_DLLS, DIRECTX_NATIVE_BUILTIN_DLLS))
+ return false;
+
const QString dllDir64 = m_prefixPath + "/drive_c/windows/system32";
const QString dllDir32 = m_prefixPath + "/drive_c/windows/syswow64";
@@ -1157,7 +1291,8 @@ bool PrefixSetupRunner::stepDirectXRuntime()
};
QMap<QString, QString> env = baseWineEnv();
- env["WINEDLLOVERRIDES"] = "mshtml=d";
+ env["WINEDLLOVERRIDES"] = makeDllOverrideEnv(
+ "mshtml=d", DIRECTX_NATIVE_DLLS, DIRECTX_NATIVE_BUILTIN_DLLS);
// 32-bit registration.
QStringList reg32 = collectRegDlls(dllDir32);
@@ -1189,6 +1324,9 @@ bool PrefixSetupRunner::stepVcrun2022()
{
emit logMessage("Installing Visual C++ 2022...");
+ if (!applyDllOverrides({}, VCRUN_DLLS))
+ return false;
+
const QString cacheDir = fluorineCacheDir() + "/vcrun2022";
const QString tmpDir = fluorineTmpDir() + "/vcrun2022";
QDir().mkpath(cacheDir);
@@ -1202,53 +1340,93 @@ bool PrefixSetupRunner::stepVcrun2022()
const QString x86Path = cacheDir + "/vc_redist.x86.exe";
if (!QFileInfo::exists(x86Path)) {
emit logMessage("Downloading vc_redist.x86.exe...");
- if (!downloadFile(VCRUN2022_X86_URL, x86Path)) {
- m_steps.last().errorMessage = "Failed to download vc_redist.x86.exe";
+ if (!downloadRuntimeInstaller(VCRUN2022_X86_URL, x86Path,
+ "vc_redist.x86.exe",
+ VCRUN2022_X86_SHA256)) {
return false;
}
+ } else if (!downloadRuntimeInstaller(VCRUN2022_X86_URL, x86Path,
+ "vc_redist.x86.exe",
+ VCRUN2022_X86_SHA256)) {
+ return false;
}
// Wine bug #57518 workaround: manually extract msvcp140.dll before running
// the installer, because the installer refuses to replace the builtin
// (builtin version number is higher).
emit logMessage("Extracting msvcp140.dll (32-bit)...");
- runHostProcess(cabextractBin, {"--directory=" + tmpDir + "/win32", x86Path, "-F", "a10"});
- runHostProcess(cabextractBin,
+ int rc = runHostProcess(cabextractBin,
+ {"--directory=" + tmpDir + "/win32", x86Path, "-F", "a10"});
+ if (rc != 0) {
+ m_steps.last().errorMessage =
+ QStringLiteral("vc_redist.x86.exe cab extraction failed (exit code %1)").arg(rc);
+ return false;
+ }
+ rc = runHostProcess(cabextractBin,
{"--directory=" + dllDir32, tmpDir + "/win32/a10", "-F", "msvcp140.dll"});
+ if (rc != 0) {
+ m_steps.last().errorMessage =
+ QStringLiteral("msvcp140.dll x86 extraction failed (exit code %1)").arg(rc);
+ return false;
+ }
// Run 32-bit installer.
emit logMessage("Running vc_redist.x86.exe...");
QMap<QString, QString> env = baseWineEnv();
- env["WINEDLLOVERRIDES"] = "mshtml=d";
+ env["WINEDLLOVERRIDES"] = makeDllOverrideEnv("mshtml=d", {}, VCRUN_DLLS);
- int rc = runProcess(m_wineBin, {x86Path, "/install", "/quiet", "/norestart"}, env);
- if (rc != 0) {
+ rc = runProcess(m_wineBin, {x86Path, "/install", "/quiet", "/norestart"}, env);
+ if (!isMicrosoftInstallerSuccess(rc)) {
m_steps.last().errorMessage =
- QStringLiteral("vc_redist.x86.exe failed (exit code %1)").arg(rc);
+ QStringLiteral("vc_redist.x86.exe failed (exit code %1, SHA256 %2)")
+ .arg(rc)
+ .arg(fileSha256(x86Path));
return false;
+ } else if (rc != 0) {
+ emit logMessage(QStringLiteral("vc_redist.x86.exe returned nonfatal exit code %1").arg(rc));
}
// Download and run x64 installer.
const QString x64Path = cacheDir + "/vc_redist.x64.exe";
if (!QFileInfo::exists(x64Path)) {
emit logMessage("Downloading vc_redist.x64.exe...");
- if (!downloadFile(VCRUN2022_X64_URL, x64Path)) {
- m_steps.last().errorMessage = "Failed to download vc_redist.x64.exe";
+ if (!downloadRuntimeInstaller(VCRUN2022_X64_URL, x64Path,
+ "vc_redist.x64.exe",
+ VCRUN2022_X64_SHA256)) {
return false;
}
+ } else if (!downloadRuntimeInstaller(VCRUN2022_X64_URL, x64Path,
+ "vc_redist.x64.exe",
+ VCRUN2022_X64_SHA256)) {
+ return false;
}
emit logMessage("Extracting msvcp140.dll (64-bit)...");
- runHostProcess(cabextractBin, {"--directory=" + tmpDir + "/win64", x64Path, "-F", "a12"});
- runHostProcess(cabextractBin,
+ rc = runHostProcess(cabextractBin,
+ {"--directory=" + tmpDir + "/win64", x64Path, "-F", "a12"});
+ if (rc != 0) {
+ m_steps.last().errorMessage =
+ QStringLiteral("vc_redist.x64.exe cab extraction failed (exit code %1)").arg(rc);
+ return false;
+ }
+ rc = runHostProcess(cabextractBin,
{"--directory=" + dllDir64, tmpDir + "/win64/a12", "-F", "msvcp140.dll"});
+ if (rc != 0) {
+ m_steps.last().errorMessage =
+ QStringLiteral("msvcp140.dll x64 extraction failed (exit code %1)").arg(rc);
+ return false;
+ }
emit logMessage("Running vc_redist.x64.exe...");
rc = runProcess(m_wineBin, {x64Path, "/install", "/quiet", "/norestart"}, env);
- if (rc != 0) {
+ if (!isMicrosoftInstallerSuccess(rc)) {
m_steps.last().errorMessage =
- QStringLiteral("vc_redist.x64.exe failed (exit code %1)").arg(rc);
+ QStringLiteral("vc_redist.x64.exe failed (exit code %1, SHA256 %2)")
+ .arg(rc)
+ .arg(fileSha256(x64Path));
return false;
+ } else if (rc != 0) {
+ emit logMessage(QStringLiteral("vc_redist.x64.exe returned nonfatal exit code %1").arg(rc));
}
QDir(tmpDir).removeRecursively();
@@ -1260,16 +1438,26 @@ bool PrefixSetupRunner::stepDotNetRuntimes()
{
emit logMessage("Installing .NET Runtimes (6-9)...");
- struct RuntimePair { const char* url32; const char* url64; const char* name; };
+ struct RuntimePair {
+ const char* url32;
+ const char* url64;
+ const char* name;
+ const QStringList* sha32;
+ const QStringList* sha64;
+ };
static const RuntimePair runtimes[] = {
- {.url32=DOTNET6_X86_URL, .url64=DOTNET6_X64_URL, .name=".NET 6"},
- {.url32=DOTNET7_X86_URL, .url64=DOTNET7_X64_URL, .name=".NET 7"},
- {.url32=DOTNET8_X86_URL, .url64=DOTNET8_X64_URL, .name=".NET 8"},
+ {.url32=DOTNET6_X86_URL, .url64=DOTNET6_X64_URL, .name=".NET 6",
+ .sha32=&DOTNET6_X86_SHA256, .sha64=&DOTNET6_X64_SHA256},
+ {.url32=DOTNET7_X86_URL, .url64=DOTNET7_X64_URL, .name=".NET 7",
+ .sha32=&DOTNET7_X86_SHA256, .sha64=&DOTNET7_X64_SHA256},
+ {.url32=DOTNET8_X86_URL, .url64=DOTNET8_X64_URL, .name=".NET 8",
+ .sha32=&DOTNET8_X86_SHA256, .sha64=&DOTNET8_X64_SHA256},
};
for (const auto& rt : runtimes) {
if (isCancelled()) return false;
- if (!stepDotNetInstallPair(rt.url32, rt.url64, rt.name))
+ if (!stepDotNetInstallPair(rt.url32, rt.url64, rt.name,
+ *rt.sha32, *rt.sha64))
return false;
}
@@ -1283,7 +1471,9 @@ bool PrefixSetupRunner::stepDotNetRuntimes()
}
bool PrefixSetupRunner::stepDotNetInstallPair(const QString& url32, const QString& url64,
- const QString& name)
+ const QString& name,
+ const QStringList& knownSha25632,
+ const QStringList& knownSha25664)
{
const QString cacheDir = fluorineCacheDir();
QDir().mkpath(cacheDir);
@@ -1298,18 +1488,27 @@ bool PrefixSetupRunner::stepDotNetInstallPair(const QString& url32, const QStrin
if (!QFileInfo::exists(path)) {
emit logMessage(QStringLiteral("Downloading %1 (32-bit)...").arg(name));
- if (!downloadFile(url32, path)) {
- m_steps.last().errorMessage = QStringLiteral("Failed to download %1 x86").arg(name);
+ if (!downloadRuntimeInstaller(url32, path,
+ QStringLiteral("%1 x86").arg(name),
+ knownSha25632)) {
return false;
}
+ } else if (!downloadRuntimeInstaller(url32, path,
+ QStringLiteral("%1 x86").arg(name),
+ knownSha25632)) {
+ return false;
}
emit logMessage(QStringLiteral("Installing %1 (32-bit)...").arg(name));
const int rc = runProcess(m_wineBin, {path, "/install", "/quiet", "/norestart"}, env);
- if (rc != 0) {
+ if (!isMicrosoftInstallerSuccess(rc)) {
m_steps.last().errorMessage =
QStringLiteral("%1 x86 installer failed (exit code %2)").arg(name).arg(rc);
return false;
+ } else if (rc != 0) {
+ emit logMessage(QStringLiteral("%1 x86 installer returned nonfatal exit code %2")
+ .arg(name)
+ .arg(rc));
}
}
@@ -1320,18 +1519,27 @@ bool PrefixSetupRunner::stepDotNetInstallPair(const QString& url32, const QStrin
if (!QFileInfo::exists(path)) {
emit logMessage(QStringLiteral("Downloading %1 (64-bit)...").arg(name));
- if (!downloadFile(url64, path)) {
- m_steps.last().errorMessage = QStringLiteral("Failed to download %1 x64").arg(name);
+ if (!downloadRuntimeInstaller(url64, path,
+ QStringLiteral("%1 x64").arg(name),
+ knownSha25664)) {
return false;
}
+ } else if (!downloadRuntimeInstaller(url64, path,
+ QStringLiteral("%1 x64").arg(name),
+ knownSha25664)) {
+ return false;
}
emit logMessage(QStringLiteral("Installing %1 (64-bit)...").arg(name));
const int rc = runProcess(m_wineBin, {path, "/install", "/quiet", "/norestart"}, env);
- if (rc != 0) {
+ if (!isMicrosoftInstallerSuccess(rc)) {
m_steps.last().errorMessage =
QStringLiteral("%1 x64 installer failed (exit code %2)").arg(name).arg(rc);
return false;
+ } else if (rc != 0) {
+ emit logMessage(QStringLiteral("%1 x64 installer returned nonfatal exit code %2")
+ .arg(name)
+ .arg(rc));
}
}
@@ -1339,7 +1547,8 @@ bool PrefixSetupRunner::stepDotNetInstallPair(const QString& url32, const QStrin
return true;
}
-bool PrefixSetupRunner::stepDotNetInstall(const QString& url, const QString& name)
+bool PrefixSetupRunner::stepDotNetInstall(const QString& url, const QString& name,
+ const QStringList& knownSha256)
{
const QString cacheDir = fluorineCacheDir();
QDir().mkpath(cacheDir);
@@ -1351,11 +1560,11 @@ bool PrefixSetupRunner::stepDotNetInstall(const QString& url, const QString& nam
if (!QFileInfo::exists(installerPath)) {
emit logMessage(QStringLiteral("Downloading %1...").arg(name));
- if (!downloadFile(url, installerPath)) {
- m_steps.last().errorMessage =
- QStringLiteral("Failed to download %1").arg(name);
+ if (!downloadRuntimeInstaller(url, installerPath, name, knownSha256)) {
return false;
}
+ } else if (!downloadRuntimeInstaller(url, installerPath, name, knownSha256)) {
+ return false;
}
emit logMessage(QStringLiteral("Installing %1...").arg(name));
@@ -1368,10 +1577,14 @@ bool PrefixSetupRunner::stepDotNetInstall(const QString& url, const QString& nam
{installerPath, "/install", "/quiet", "/norestart"},
env);
- if (rc != 0) {
+ if (!isMicrosoftInstallerSuccess(rc)) {
m_steps.last().errorMessage =
QStringLiteral("%1 installer failed (exit code %2)").arg(name).arg(rc);
return false;
+ } else if (rc != 0) {
+ emit logMessage(QStringLiteral("%1 installer returned nonfatal exit code %2")
+ .arg(name)
+ .arg(rc));
}
return true;
@@ -1482,6 +1695,9 @@ bool PrefixSetupRunner::stepWineRegistry()
return false;
}
+ if (!applyDllOverrides(DIRECTX_NATIVE_DLLS, DIRECTX_NATIVE_BUILTIN_DLLS + VCRUN_DLLS))
+ return false;
+
emit logMessage("Registry settings applied successfully");
return true;
}
@@ -1570,17 +1786,144 @@ bool PrefixSetupRunner::downloadFile(const QString& url, const QString& destPath
return true;
}
-bool PrefixSetupRunner::verifySha256(const QString& filePath, const QString& expectedHex)
+bool PrefixSetupRunner::downloadRuntimeInstaller(const QString& url,
+ const QString& destPath,
+ const QString& displayName,
+ const QStringList& knownSha256)
+{
+ const QString stampPath = destPath + ".sha256";
+ bool refreshExisting = false;
+ bool knownHashMatch = false;
+ if (QFileInfo::exists(destPath) &&
+ validateRuntimeInstaller(destPath, displayName, knownSha256,
+ &knownHashMatch)) {
+ if (knownSha256.isEmpty() || knownHashMatch)
+ return true;
+
+ const QString cachedSha = fileSha256(destPath);
+ QFile stamp(stampPath);
+ if (!cachedSha.isEmpty() && stamp.open(QIODevice::ReadOnly | QIODevice::Text) &&
+ QString::fromUtf8(stamp.readAll()).trimmed().compare(
+ cachedSha, Qt::CaseInsensitive) == 0) {
+ emit logMessage(QStringLiteral(
+ "Cached %1 has a previously accepted rolling hash.").arg(displayName));
+ return true;
+ }
+
+ emit logMessage(QStringLiteral(
+ "Cached %1 has an unknown hash, refreshing once...").arg(displayName));
+ refreshExisting = true;
+ }
+
+ if (QFileInfo::exists(destPath) && !refreshExisting) {
+ emit logMessage(QStringLiteral("Cached %1 failed validation, re-downloading...")
+ .arg(displayName));
+ QFile::remove(destPath);
+ }
+
+ const QString partPath = destPath + ".part";
+ QFile::remove(partPath);
+ if (!downloadFile(url, partPath)) {
+ QFile::remove(partPath);
+ m_steps.last().errorMessage =
+ QStringLiteral("Failed to download %1").arg(displayName);
+ return false;
+ }
+
+ if (!validateRuntimeInstaller(partPath, displayName, knownSha256,
+ &knownHashMatch)) {
+ QFile::remove(partPath);
+ return false;
+ }
+
+ QFile::remove(destPath);
+ if (!QFile::rename(partPath, destPath)) {
+ QFile::remove(partPath);
+ m_steps.last().errorMessage =
+ QStringLiteral("Failed to cache %1").arg(displayName);
+ return false;
+ }
+
+ QFile stamp(stampPath);
+ if (stamp.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
+ stamp.write(fileSha256(destPath).toUtf8());
+ stamp.write("\n");
+ }
+
+ return true;
+}
+
+bool PrefixSetupRunner::validateRuntimeInstaller(const QString& path,
+ const QString& displayName,
+ const QStringList& knownSha256,
+ bool* knownHashMatch)
+{
+ if (knownHashMatch)
+ *knownHashMatch = false;
+
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ m_steps.last().errorMessage =
+ QStringLiteral("Failed to read %1").arg(displayName);
+ return false;
+ }
+
+ if (file.size() < 1024 * 1024) {
+ m_steps.last().errorMessage =
+ QStringLiteral("%1 is too small to be a valid installer").arg(displayName);
+ return false;
+ }
+
+ const QByteArray magic = file.read(2);
+ if (magic != "MZ") {
+ m_steps.last().errorMessage =
+ QStringLiteral("%1 is not a Windows PE installer").arg(displayName);
+ return false;
+ }
+ file.close();
+
+ const QString sha = fileSha256(path);
+ if (sha.isEmpty()) {
+ m_steps.last().errorMessage =
+ QStringLiteral("Failed to hash %1").arg(displayName);
+ return false;
+ }
+
+ emit logMessage(QStringLiteral("%1 SHA256: %2").arg(displayName, sha));
+
+ if (!knownSha256.isEmpty()) {
+ const bool matched = knownSha256.contains(sha, Qt::CaseInsensitive);
+ if (knownHashMatch)
+ *knownHashMatch = matched;
+ if (!matched) {
+ emit logMessage(
+ QStringLiteral(
+ "WARNING: %1 hash is not in the known list; accepting because "
+ "the installer passed PE validation and Microsoft may have "
+ "updated the rolling download.")
+ .arg(displayName));
+ }
+ }
+
+ return true;
+}
+
+QString PrefixSetupRunner::fileSha256(const QString& filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly))
- return false;
+ return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
if (!hash.addData(&file))
- return false;
+ return {};
- return hash.result().toHex() == expectedHex.toLatin1();
+ return QString::fromLatin1(hash.result().toHex());
+}
+
+bool PrefixSetupRunner::verifySha256(const QString& filePath, const QString& expectedHex)
+{
+ return fileSha256(filePath).compare(expectedHex, Qt::CaseInsensitive) == 0;
}
bool PrefixSetupRunner::downloadAndVerify(const QString& url, const QString& destPath,
@@ -1879,3 +2222,72 @@ QMap<QString, QString> PrefixSetupRunner::baseWineEnv() const
env["WINESERVER"] = m_wineserverBin;
return env;
}
+
+bool PrefixSetupRunner::applyDllOverrides(const QStringList& native,
+ const QStringList& nativeBuiltin)
+{
+ if (native.isEmpty() && nativeBuiltin.isEmpty())
+ return true;
+
+ const QString tmpDir = fluorineTmpDir();
+ QDir().mkpath(tmpDir);
+
+ QString regContent = QStringLiteral(
+ "Windows Registry Editor Version 5.00\n\n"
+ "[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]\n");
+
+ auto appendOverrides = [&regContent](const QStringList& dlls,
+ const QString& mode) {
+ for (const QString& dll : dlls) {
+ regContent += QStringLiteral("\"*%1\"=\"%2\"\n").arg(dll, mode);
+ }
+ };
+
+ appendOverrides(native, QStringLiteral("native"));
+ appendOverrides(nativeBuiltin, QStringLiteral("native,builtin"));
+ regContent += QLatin1Char('\n');
+
+ const QString regFile = tmpDir + "/dependency_overrides.reg";
+ QFile f(regFile);
+ if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ m_steps.last().errorMessage = "Failed to write dependency override registry";
+ return false;
+ }
+ f.write(regContent.toUtf8());
+ f.close();
+
+ QMap<QString, QString> env = baseWineEnv();
+ env["WINEDLLOVERRIDES"] = "mshtml=d";
+ env["PROTON_USE_XALIA"] = "0";
+
+ const int rc = runProcess(m_wineBin, {"regedit", regFile}, env);
+ QFile::remove(regFile);
+ if (rc != 0) {
+ m_steps.last().errorMessage =
+ QStringLiteral("dependency override import failed (exit code %1)").arg(rc);
+ return false;
+ }
+
+ return true;
+}
+
+QString PrefixSetupRunner::makeDllOverrideEnv(const QString& base,
+ const QStringList& native,
+ const QStringList& nativeBuiltin)
+{
+ QStringList entries;
+ if (!base.trimmed().isEmpty())
+ entries.append(base.split(';', Qt::SkipEmptyParts));
+
+ for (const QString& dll : native)
+ entries.append(QStringLiteral("%1=n").arg(dll));
+ for (const QString& dll : nativeBuiltin)
+ entries.append(QStringLiteral("%1=n,b").arg(dll));
+
+ return entries.join(QLatin1Char(';'));
+}
+
+bool PrefixSetupRunner::isMicrosoftInstallerSuccess(int exitCode)
+{
+ return exitCode == 0 || exitCode == 105 || exitCode == 194 || exitCode == 236;
+}
diff --git a/src/src/prefixsetuprunner.h b/src/src/prefixsetuprunner.h
index 3797c40..9f3f0c8 100644
--- a/src/src/prefixsetuprunner.h
+++ b/src/src/prefixsetuprunner.h
@@ -6,6 +6,7 @@
#include <QObject>
#include <QProcess>
#include <QString>
+#include <QStringList>
#include <QVector>
#include <functional>
@@ -97,9 +98,12 @@ private:
bool stepDirectXRuntime();
bool stepVcrun2022();
bool stepDotNetRuntimes();
- bool stepDotNetInstall(const QString& url, const QString& name);
+ bool stepDotNetInstall(const QString& url, const QString& name,
+ const QStringList& knownSha256 = {});
bool stepDotNetInstallPair(const QString& url32, const QString& url64,
- const QString& name);
+ const QString& name,
+ const QStringList& knownSha25632 = {},
+ const QStringList& knownSha25664 = {});
bool stepGameDetection();
bool stepWineRegistry();
bool stepWin11Mode();
@@ -114,6 +118,13 @@ private:
bool downloadFile(const QString& url, const QString& destPath);
bool downloadAndVerify(const QString& url, const QString& destPath,
const QString& expectedSha256);
+ bool downloadRuntimeInstaller(const QString& url, const QString& destPath,
+ const QString& displayName,
+ const QStringList& knownSha256 = {});
+ bool validateRuntimeInstaller(const QString& path, const QString& displayName,
+ const QStringList& knownSha256,
+ bool* knownHashMatch = nullptr);
+ static QString fileSha256(const QString& filePath);
static bool verifySha256(const QString& filePath, const QString& expectedHex);
bool ensure7zz();
bool ensureWinetricks();
@@ -129,6 +140,12 @@ private:
static QString fluorineCacheDir() ;
static QString fluorineTmpDir() ;
QMap<QString, QString> baseWineEnv() const;
+ bool applyDllOverrides(const QStringList& native,
+ const QStringList& nativeBuiltin);
+ static QString makeDllOverrideEnv(const QString& base,
+ const QStringList& native,
+ const QStringList& nativeBuiltin);
+ static bool isMicrosoftInstallerSuccess(int exitCode);
/// Kill any wineboot/wineserver/pv-adverb processes still bound to
/// m_prefixPath from a previous aborted run. Safe to call when none exist.
diff --git a/src/src/settingsdialogupdates.cpp b/src/src/settingsdialogupdates.cpp
index d750725..8f76bae 100644
--- a/src/src/settingsdialogupdates.cpp
+++ b/src/src/settingsdialogupdates.cpp
@@ -318,23 +318,24 @@ void UpdatesSettingsTab::onInstall()
return;
}
- // The tarball is packed as `fluorine-manager/…`. Locate the
- // top-level dir (picks up a rename too, just in case).
+ // Release assets are not consistent: tarballs are packed as
+ // `fluorine-manager/...`, while zips may extract files directly
+ // into the root. Accept both layouts.
QDir extract(extractDir);
- const QStringList tops = extract.entryList(
- QDir::Dirs | QDir::NoDotAndDotDot);
- if (tops.isEmpty()) {
- m_statusLabel->setText(
- tr("<i>Install failed:</i> extracted archive is empty"));
- m_progressBar->setVisible(false);
- m_installButton->setEnabled(true);
- m_checkNowButton->setEnabled(true);
- return;
+ QString newLauncher =
+ extract.absoluteFilePath(QStringLiteral("fluorine-manager"));
+ if (!QFileInfo::exists(newLauncher)) {
+ const QStringList tops = extract.entryList(
+ QDir::Dirs | QDir::NoDotAndDotDot);
+ for (const QString& top : tops) {
+ const QString candidate = extract.absoluteFilePath(
+ top + QStringLiteral("/fluorine-manager"));
+ if (QFileInfo::exists(candidate)) {
+ newLauncher = candidate;
+ break;
+ }
+ }
}
- const QString newBundle =
- extract.absoluteFilePath(tops.first());
- const QString newLauncher =
- newBundle + QStringLiteral("/fluorine-manager");
if (!QFileInfo::exists(newLauncher)) {
m_statusLabel->setText(
tr("<i>Install failed:</i> launcher not found in "
diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp
index ce7b6f0..2bd3549 100644
--- a/src/src/vfs/mo2filesystem.cpp
+++ b/src/src/vfs/mo2filesystem.cpp
@@ -337,6 +337,36 @@ std::string joinPath(const std::string& base, const std::string& name)
return base + "/" + name;
}
+bool isSameOrDescendant(const std::string& path, const std::string& root)
+{
+ const fs::path normPath = fs::path(path).lexically_normal();
+ const fs::path normRoot = fs::path(root).lexically_normal();
+ const std::string pathStr = normPath.string();
+ std::string rootStr = normRoot.string();
+ if (pathStr == rootStr) {
+ return true;
+ }
+ if (!rootStr.empty() && rootStr.back() != '/') {
+ rootStr.push_back('/');
+ }
+ return pathStr.rfind(rootStr, 0) == 0;
+}
+
+std::string originForPath(Mo2FsContext* ctx, const std::string& realPath)
+{
+ if (ctx != nullptr) {
+ const std::string stagingRoot = ctx->overwrite->stagingPath("");
+ const std::string overwriteRoot = ctx->overwrite->overwritePath("");
+ if (isSameOrDescendant(realPath, stagingRoot)) {
+ return "Staging";
+ }
+ if (isSameOrDescendant(realPath, overwriteRoot)) {
+ return "Overwrite";
+ }
+ }
+ return "Mod";
+}
+
// Look up the canonical (mod-provided) display name for a child entry.
// Returns the display name if found, or the original name if not.
std::string canonicalChildName(const Mo2FsContext* ctx, const std::string& parentPath,
@@ -1413,9 +1443,12 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
// 1. Files already in staging/overwrite → open R/W directly.
// 2. Tracked files (user moved from Overwrite to a mod) → open R/W
// in-place so writes go back to the user's dedicated mod folder.
- // 3. Everything else (untracked mod files, base-game files) → open R/O
- // now, lazy COW to Overwrite on first actual write(). This avoids
- // COW from Wine metadata ops (O_RDWR without writing).
+ // 3. Existing mod/data-dir files → open R/W in place.
+ // 4. Base-game backing files → open R/O and COW to staging only on the
+ // first actual write().
+ //
+ // This matches upstream USVFS behavior more closely than the previous
+ // conservative "copy everything writable into overwrite" approach.
//
// Read strategy: always open a real backing fd at open() time so mo2_read
// can splice from it without re-opening per read call. Avoids N syscalls
@@ -1451,17 +1484,17 @@ void mo2_open(fuse_req_t req, fuse_ino_t ino, struct fuse_file_info* fi)
if (fd < 0 && alreadyStaged) {
// Already in staging/overwrite — open R/W directly.
fd = open(realPath.c_str(), O_RDWR);
- } else if (fd < 0 && !trackedMod.empty()) {
- // Should not reach here, but safety fallback
- fd = open(realPath.c_str(), O_RDWR);
- } else if (fd < 0) {
- // Untracked file — open R/O, defer COW to first write().
- if (isBacking && ctx->backing_dir_fd >= 0) {
+ } else if (fd < 0 && isBacking) {
+ // Backing file — open R/O, defer COW to first write().
+ if (ctx->backing_dir_fd >= 0) {
fd = openat(ctx->backing_dir_fd, realPath.c_str(), O_RDONLY);
} else {
fd = open(realPath.c_str(), O_RDONLY);
}
cowPending = true;
+ } else if (fd < 0) {
+ // Existing mod/data-dir file — write in place.
+ fd = open(realPath.c_str(), O_RDWR);
}
if (fd < 0) {
fuse_reply_err(req, EIO);
@@ -1589,8 +1622,9 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
return;
}
- // Lazy COW: first actual write on an untracked file — copy to Overwrite
- // staging and reopen the fd read-write on the new copy.
+ // Lazy COW applies only to backing-game files. Existing mod files should
+ // stay in place so config writes update the source mod rather than creating
+ // a shadow copy in overwrite.
if (cowPending) {
try {
std::string newPath;
@@ -1620,7 +1654,7 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
}
}
realPath = newPath;
- updateFileNode(ctx, relativePath, newPath, "Staging");
+ updateFileNode(ctx, relativePath, newPath, originForPath(ctx, newPath));
} catch (...) {
fuse_reply_err(req, EIO);
return;
@@ -1639,8 +1673,8 @@ void mo2_write(fuse_req_t req, fuse_ino_t /*ino*/, const char* buf, size_t size,
}
if (!cowPending) {
- // Update VFS tree size/mtime for in-place writes (tracked files)
- updateFileNode(ctx, relativePath, realPath, "Mod");
+ // Update VFS tree size/mtime for in-place writes.
+ updateFileNode(ctx, relativePath, realPath, originForPath(ctx, realPath));
}
fuse_reply_write(req, static_cast<size_t>(written));
}
@@ -1879,7 +1913,6 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
if ((to_set & FUSE_SET_ATTR_SIZE) != 0 && attr != nullptr) {
std::string target;
bool targetIsBacking = false;
- bool targetIsTracked = false;
uint64_t fh = 0;
if (fi != nullptr) {
@@ -1889,7 +1922,6 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
if (it != ctx->open_files.end()) {
target = it->second.real_path;
targetIsBacking = it->second.is_backing;
- targetIsTracked = it->second.is_tracked;
}
}
@@ -1910,16 +1942,16 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
const std::string modFilePath = trackedMod + "/" + path;
if (fs::exists(modFilePath)) {
target = modFilePath;
- targetIsTracked = true;
targetIsBacking = false;
}
}
}
- // COW for untracked files: copy to staging before truncating.
- // Tracked files and files already in staging are truncated in-place.
+ // COW only for backing files: copy to staging before truncating.
+ // Tracked files, staging files, and existing mod files are truncated
+ // in place.
const std::string stagedPath = ctx->overwrite->stagingPath(path);
- if (!targetIsTracked &&
+ if (targetIsBacking &&
fs::path(target).lexically_normal().string() !=
fs::path(stagedPath).lexically_normal().string()) {
try {
@@ -1974,7 +2006,7 @@ void mo2_setattr(fuse_req_t req, fuse_ino_t ino, struct stat* attr, int to_set,
}
}
- updateFileNode(ctx, path, target, targetIsTracked ? "Mod" : "Staging");
+ updateFileNode(ctx, path, target, originForPath(ctx, target));
}
// Handle chmod — propagate permission changes to the real file on disk.
diff --git a/src/src/vfs/trackedwrites.cpp b/src/src/vfs/trackedwrites.cpp
index 10d14e5..d59d1e0 100644
--- a/src/src/vfs/trackedwrites.cpp
+++ b/src/src/vfs/trackedwrites.cpp
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <cstdio>
+#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
@@ -71,6 +72,47 @@ void skipWhitespace(const std::string& data, size_t& pos)
++pos;
}
+bool filesIdentical(const fs::path& lhs, const fs::path& rhs)
+{
+ std::error_code lhsEc;
+ std::error_code rhsEc;
+ const auto lhsSize = fs::file_size(lhs, lhsEc);
+ const auto rhsSize = fs::file_size(rhs, rhsEc);
+ if (lhsEc || rhsEc || lhsSize != rhsSize) {
+ return false;
+ }
+
+ std::ifstream left(lhs, std::ios::binary);
+ std::ifstream right(rhs, std::ios::binary);
+ if (!left.is_open() || !right.is_open()) {
+ return false;
+ }
+
+ constexpr std::size_t kChunk = 64 * 1024;
+ std::vector<char> leftBuf(kChunk);
+ std::vector<char> rightBuf(kChunk);
+
+ while (left && right) {
+ left.read(leftBuf.data(), static_cast<std::streamsize>(leftBuf.size()));
+ right.read(rightBuf.data(), static_cast<std::streamsize>(rightBuf.size()));
+
+ const std::streamsize leftCount = left.gcount();
+ const std::streamsize rightCount = right.gcount();
+ if (leftCount != rightCount) {
+ return false;
+ }
+ if (leftCount == 0) {
+ break;
+ }
+ if (std::memcmp(leftBuf.data(), rightBuf.data(),
+ static_cast<std::size_t>(leftCount)) != 0) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
} // namespace
void TrackedWrites::load(const std::string& path)
@@ -426,3 +468,58 @@ std::unordered_map<std::string, std::string> TrackedWrites::allMappings() const
std::lock_guard lock(m_mutex);
return m_tracked;
}
+
+std::vector<TrackedWrites::DuplicateEntry>
+TrackedWrites::scanOverwriteDuplicates(
+ const std::string& overwrite_dir,
+ const std::vector<std::pair<std::string, std::string>>& mods) const
+{
+ std::vector<DuplicateEntry> result;
+
+ std::error_code ec;
+ if (!fs::exists(overwrite_dir, ec)) {
+ return result;
+ }
+
+ for (auto it = fs::recursive_directory_iterator(
+ overwrite_dir, fs::directory_options::skip_permission_denied, ec);
+ it != fs::recursive_directory_iterator(); ++it) {
+ if (!it->is_regular_file(ec)) {
+ continue;
+ }
+
+ auto rel = fs::relative(it->path(), overwrite_dir, ec);
+ if (ec) {
+ ec.clear();
+ continue;
+ }
+
+ std::string matchedModName;
+ std::string matchedModPath;
+ for (const auto& [modName, modPath] : mods) {
+ std::error_code modEc;
+ if (fs::exists(fs::path(modPath) / rel, modEc) && !modEc) {
+ matchedModName = modName;
+ matchedModPath = modPath;
+ }
+ }
+
+ if (matchedModPath.empty()) {
+ continue;
+ }
+
+ const fs::path modFile = fs::path(matchedModPath) / rel;
+ const bool identical = filesIdentical(it->path(), modFile);
+
+ result.push_back(DuplicateEntry{
+ .relative_path = rel.string(),
+ .overwrite_path = it->path().string(),
+ .mod_name = matchedModName,
+ .mod_path = matchedModPath,
+ .state = identical ? DuplicateState::Identical
+ : DuplicateState::Different,
+ });
+ }
+
+ return result;
+}
diff --git a/src/src/vfs/trackedwrites.h b/src/src/vfs/trackedwrites.h
index a8ebe08..6f414ec 100644
--- a/src/src/vfs/trackedwrites.h
+++ b/src/src/vfs/trackedwrites.h
@@ -21,6 +21,21 @@
class TrackedWrites
{
public:
+ enum class DuplicateState
+ {
+ Identical,
+ Different,
+ };
+
+ struct DuplicateEntry
+ {
+ std::string relative_path;
+ std::string overwrite_path;
+ std::string mod_name;
+ std::string mod_path;
+ DuplicateState state;
+ };
+
TrackedWrites() = default;
// Load tracking data from a JSON file.
@@ -66,6 +81,13 @@ public:
const std::string& overwrite_dir,
const std::vector<std::pair<std::string, std::string>>& mods);
+ // Read-only duplicate review: for every file in overwrite, check whether the
+ // same relative path exists in any enabled mod. The highest-priority match
+ // wins. No mutation is performed.
+ std::vector<DuplicateEntry> scanOverwriteDuplicates(
+ const std::string& overwrite_dir,
+ const std::vector<std::pair<std::string, std::string>>& mods) const;
+
// Get all tracked mappings (for debugging / inspection).
std::unordered_map<std::string, std::string> allMappings() const;
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 8e71366..24c4597 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -48,6 +48,27 @@ target_link_libraries(test_external_dir_mapping PRIVATE
)
add_test(NAME test_external_dir_mapping COMMAND test_external_dir_mapping)
+# ---------------------------------------------------------------------------
+# Overwrite duplicate scan tests
+# ---------------------------------------------------------------------------
+add_executable(test_overwrite_duplicates EXCLUDE_FROM_ALL
+ test_overwrite_duplicates.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/vfs/trackedwrites.cpp
+)
+set_target_properties(test_overwrite_duplicates PROPERTIES
+ AUTOMOC OFF
+ CXX_STANDARD 23
+ CXX_STANDARD_REQUIRED ON
+)
+target_include_directories(test_overwrite_duplicates PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/src
+)
+target_link_libraries(test_overwrite_duplicates PRIVATE
+ GTest::gtest
+ GTest::gtest_main
+)
+add_test(NAME test_overwrite_duplicates COMMAND test_overwrite_duplicates)
+
# Register a "fluorine-tests" umbrella target that builds every test exe.
add_custom_target(fluorine-tests
- DEPENDS test_metainiutils test_external_dir_mapping)
+ DEPENDS test_metainiutils test_external_dir_mapping test_overwrite_duplicates)
diff --git a/src/tests/test_overwrite_duplicates.cpp b/src/tests/test_overwrite_duplicates.cpp
new file mode 100644
index 0000000..8cbf3ec
--- /dev/null
+++ b/src/tests/test_overwrite_duplicates.cpp
@@ -0,0 +1,71 @@
+#include "vfs/trackedwrites.h"
+
+#include <strings.h>
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <filesystem>
+#include <fstream>
+
+namespace fs = std::filesystem;
+
+namespace
+{
+
+void writeText(const fs::path& path, const std::string& text)
+{
+ fs::create_directories(path.parent_path());
+ std::ofstream out(path, std::ios::binary | std::ios::trunc);
+ ASSERT_TRUE(out.is_open());
+ out << text;
+}
+
+} // namespace
+
+TEST(OverwriteDuplicates, DetectsIdenticalAndDifferentMatches)
+{
+ const fs::path base = fs::temp_directory_path() / "fluorine_overwrite_dup_test";
+ fs::remove_all(base);
+
+ const fs::path overwrite = base / "overwrite";
+ const fs::path modA = base / "ModA";
+ const fs::path modB = base / "ModB";
+
+ writeText(overwrite / "SKSE/Plugins/Identical.ini", "same\n");
+ writeText(overwrite / "SKSE/Plugins/Different.ini", "overwrite\n");
+ writeText(overwrite / "SKSE/Plugins/OnlyOverwrite.ini", "only overwrite\n");
+
+ writeText(modA / "SKSE/Plugins/Identical.ini", "same\n");
+ writeText(modA / "SKSE/Plugins/Different.ini", "mod a\n");
+ writeText(modB / "SKSE/Plugins/Different.ini", "mod b wins\n");
+
+ TrackedWrites tracker;
+ const std::vector<std::pair<std::string, std::string>> mods = {
+ {"ModA", modA.string()},
+ {"ModB", modB.string()},
+ };
+
+ const auto duplicates = tracker.scanOverwriteDuplicates(overwrite.string(), mods);
+ ASSERT_EQ(duplicates.size(), 2u);
+
+ const auto identical = std::find_if(
+ duplicates.begin(), duplicates.end(),
+ [](const TrackedWrites::DuplicateEntry& entry) {
+ return entry.relative_path == "SKSE/Plugins/Identical.ini";
+ });
+ ASSERT_NE(identical, duplicates.end());
+ EXPECT_EQ(identical->mod_name, "ModA");
+ EXPECT_EQ(identical->state, TrackedWrites::DuplicateState::Identical);
+
+ const auto different = std::find_if(
+ duplicates.begin(), duplicates.end(),
+ [](const TrackedWrites::DuplicateEntry& entry) {
+ return entry.relative_path == "SKSE/Plugins/Different.ini";
+ });
+ ASSERT_NE(different, duplicates.end());
+ EXPECT_EQ(different->mod_name, "ModB");
+ EXPECT_EQ(different->state, TrackedWrites::DuplicateState::Different);
+
+ fs::remove_all(base);
+}