diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-23 09:59:01 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-05-23 09:59:01 -0500 |
| commit | 7f0ded9e4a6e11b5e0327dd8eb76dd5945b8693d (patch) | |
| tree | bfbc73c5c4c9f6cc5f196129fe8e0b583ebee639 | |
| parent | b796e336c4795f5c208368aa09b178d905f46173 (diff) | |
prefix: align dependency setup and VFS fixes
| -rw-r--r-- | libs/game_bethesda/src/games/skyrimse/gameskyrimse.cpp | 2 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 29 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.cpp | 486 | ||||
| -rw-r--r-- | src/src/prefixsetuprunner.h | 21 | ||||
| -rw-r--r-- | src/src/settingsdialogupdates.cpp | 31 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 74 | ||||
| -rw-r--r-- | src/src/vfs/trackedwrites.cpp | 97 | ||||
| -rw-r--r-- | src/src/vfs/trackedwrites.h | 22 | ||||
| -rw-r--r-- | src/tests/CMakeLists.txt | 23 | ||||
| -rw-r--r-- | src/tests/test_overwrite_duplicates.cpp | 71 |
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 = [®Content](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); +} |
